ts-unused 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +295 -0
  3. package/dist/cli.js +915 -0
  4. package/package.json +32 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Alex Gorbatchev
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,295 @@
1
+ # ts-unused
2
+
3
+ A CLI tool that analyzes TypeScript projects to find unused exports and unused properties in types and interfaces.
4
+
5
+ **ts-unused** is built with AI-assisted development in mind. Its primary goal is to audit codebases, especially those heavily modified by AI coding assistants, to identify dead code, unused types, _properties_, and exports. The intended workflow is to feed the tool's output back into an AI agent, allowing it to autonomously clean up and refactor the project. VSCode problem matcher makes it a fully integrated experience.
6
+
7
+ ## Features
8
+
9
+ - **Finds Unused Exports**: Identifies functions, classes, types, interfaces, and constants that are exported but never imported or used elsewhere
10
+ - **Finds Completely Unused Files**: Identifies files where all exports are unused, suggesting the entire file can be deleted
11
+ - **Finds Unused Type Properties**: Detects properties in interfaces and type aliases that are defined but never accessed
12
+ - **Auto-Fix Command**: Automatically removes unused exports, properties, and deletes unused files with git safety checks
13
+ - **Structural Property Equivalence**: Handles property re-declarations across multiple interfaces - properties are considered "used" if structurally equivalent properties (same name and type) are accessed in any interface
14
+ - **Three-Tier Severity System**: Categorizes findings by severity level for better prioritization
15
+ - **ERROR**: Completely unused code that should be removed
16
+ - **WARNING**: Items with TODO comments that need attention
17
+ - **INFO**: Items only used in tests (testing helpers)
18
+ - **TODO Comment Detection**: Identifies and highlights properties/types with TODO comments
19
+ - **Excludes Test Files**: Automatically excludes test files from analysis to avoid false positives (but tracks test-only usage)
20
+ - **Excludes @ts-nocheck Files**: Automatically excludes files with `// @ts-nocheck` on the first line
21
+ - **Inline Type Support**: Analyzes inline object types in addition to named interfaces and type aliases
22
+ - **VS Code Integration**: Problem matcher for displaying results in VS Code's Problems panel with severity indicators
23
+
24
+ ## Limitations
25
+
26
+ - **Dynamic Access**: Properties accessed via computed property names (`obj[key]`) may not be detected
27
+ - **Re-exports**: Items that are re-exported through barrel files are considered used
28
+ - **Type Narrowing**: Properties accessed after type guards or conditional checks may not always be tracked through complex control flow
29
+
30
+ ## Comparison With Similar Tools
31
+
32
+ ### Feature Comparison
33
+
34
+ | Feature | `ts-unused` | [`tsr`](https://github.com/line/tsr) | [`ts-unused-exports`](https://github.com/pzavolinsky/ts-unused-exports) |
35
+ | :--- | :--- | :--- | :--- |
36
+ | **Goal** | **Report & Analyze** | **Remove (Tree-Shake)** | **Report** |
37
+ | **Unused Exports** | ✅ Detects and reports | ✅ Detects and removes | ✅ Detects and reports |
38
+ | **Unused Properties** | ✅ **Unique:** Checks `interface`/`type` properties | ❌ No property checks | ❌ No property checks |
39
+ | **Test-Only Usage** | ✅ **Unique:** Identifies "Used only in tests" | ⚠️ May delete if not entrypoint | ❌ No distinction |
40
+ | **Comment Support** | ✅ **Unique:** TODOs change severity | ✅ Skip/Ignore only | ✅ Skip/Ignore only |
41
+ | **Unused Files** | ✅ Reports completely unused files | ✅ Deletes unreachable files | ✅ Explicit report flag |
42
+ | **VS Code Integration** | ✅ **Problem Matcher provided** | ❌ Manual setup required | ❌ Manual setup / ESLint plugin |
43
+ | **Auto-Fix** | ✅ Removes unused code safely | ✅ **Primary Feature:** Auto-removes code | ❌ Manual removal required |
44
+ | **Accuracy** | ⭐️ **High** (Language Service) | ⚡️ **Fast** (Custom Graph) | ⚡️ **Fast** (Custom Parser) |
45
+ | **Entrypoints** | 🟢 Not required (Global scan) | 🔴 **Required** (Reachability graph) | 🟢 Not required (Global scan) |
46
+
47
+ ### vs [tsr](https://github.com/line/tsr)
48
+
49
+ **tsr** is primarily a "tree-shaking" tool for source code, designed to automatically remove unused code.
50
+
51
+ - **Goal**: `tsr` focuses on **removing** code (autofix), while `ts-unused` focuses on **reporting** and analysis.
52
+ - **Detection**: `tsr` uses a reachability graph starting from defined entrypoints. If code isn't reachable, it's deleted. `ts-unused` scans all files and checks for global references using the TypeScript Language Service.
53
+
54
+ ### vs [ts-unused-exports](https://github.com/pzavolinsky/ts-unused-exports)
55
+
56
+ **ts-unused-exports** is a specialized, high-performance tool for finding unused exported symbols.
57
+
58
+ - **Performance**: `ts-unused-exports` uses a custom parser and resolver, making it potentially faster on very large codebases than `ts-unused` (which uses the full TypeScript Language Service).
59
+ - **Accuracy**: `ts-unused` leverages the standard TypeScript Language Service, ensuring higher accuracy with complex re-exports, type inference, and aliasing.
60
+ - **Granularity**: `ts-unused` provides deeper analysis for **unused properties** and **test-only usage**, whereas `ts-unused-exports` focuses strictly on exported symbols.
61
+
62
+ ## Installation
63
+
64
+ ```bash
65
+ npm install -g ts-unused
66
+ # or
67
+ bun add -g ts-unused
68
+ ```
69
+
70
+ ## Usage
71
+
72
+ ### Check Command (Analysis Only)
73
+
74
+ Analyze your project and report unused items without making any changes:
75
+
76
+ ```bash
77
+ ts-unused check <path-to-tsconfig.json> [file-path-to-check]
78
+ # or simply:
79
+ ts-unused <path-to-tsconfig.json> [file-path-to-check]
80
+ ```
81
+
82
+ ### Fix Command (Auto-Remove)
83
+
84
+ Automatically remove unused exports, properties, and delete unused files:
85
+
86
+ ```bash
87
+ ts-unused fix <path-to-tsconfig.json>
88
+ ```
89
+
90
+ **Safety Features:**
91
+ - Checks git status before modifying each file
92
+ - Skips files with uncommitted local changes
93
+ - Reports which files were skipped
94
+ - Continues processing other files if one fails
95
+ - Provides detailed per-file logging
96
+
97
+ ### Example
98
+
99
+ ```bash
100
+ # Analyze only
101
+ ts-unused ./tsconfig.json
102
+
103
+ # Auto-fix
104
+ ts-unused fix ./tsconfig.json
105
+ ```
106
+
107
+ ## Output
108
+
109
+ ### Check Command Output
110
+
111
+ The script outputs:
112
+
113
+ 1. **Completely Unused Files**: Files where all exports are unused (candidates for deletion)
114
+ 2. **Unused Exports**: Functions, types, interfaces, and constants that are exported but never used
115
+ 3. **Unused Properties**: Properties in types/interfaces that are defined but never accessed
116
+ 3. **Summary**: Total count of unused items found
117
+
118
+ Each finding includes:
119
+ - File path relative to the project root
120
+ - Export/property name with location (`name:line:startColumn-endColumn`)
121
+ - Severity level (`[ERROR]`, `[WARNING]`, or `[INFO]`)
122
+ - Description of the issue
123
+
124
+ The column positions are 1-based (VS Code standard) and the range highlights the entire identifier name in VS Code's editor and Problems panel.
125
+
126
+ ### Example Check Output
127
+
128
+ ```
129
+ Analyzing TypeScript project: /path/to/tsconfig.json
130
+
131
+ Unused Exports:
132
+
133
+ packages/example/src/helpers.ts
134
+ unusedFunction:10:1-15 [ERROR] (Unused function)
135
+ createTestHelper:25:1-17 [INFO] (Used only in tests)
136
+
137
+ packages/example/src/constants.ts
138
+ UNUSED_CONSTANT:20:7-22 [ERROR] (Unused const)
139
+
140
+ Unused Type/Interface Properties:
141
+
142
+ packages/example/src/types.ts
143
+ UserConfig.unusedProp:5:3-13 [ERROR] (Unused property)
144
+ UserConfig.futureFeature:12:3-16 [WARNING] (Unused property: [TODO] implement this later)
145
+ TestHelpers.mockData:8:3-11 [INFO] (Used only in tests)
146
+
147
+ Summary:
148
+ Unused exports: 3
149
+ Unused properties: 3
150
+ ```
151
+
152
+ ### Example Fix Output
153
+
154
+ ```
155
+ Fixing TypeScript project: /path/to/tsconfig.json
156
+
157
+ Deleting: src/unused-file.ts (all exports unused)
158
+ Fixing: src/helpers.ts
159
+ Removed unused export: unusedFunction
160
+ Fixing: src/types.ts
161
+ Removed unused property: UserConfig.unusedProp
162
+ Skipped: src/modified.ts (has local git changes)
163
+
164
+ Summary:
165
+ Fixed exports: 1
166
+ Fixed properties: 1
167
+ Deleted files: 1
168
+ Skipped files: 1
169
+
170
+ Skipped files (have local git changes):
171
+ src/modified.ts
172
+ ```
173
+
174
+ ### Severity Levels
175
+
176
+ - **[ERROR]** - Completely unused code (red in VS Code)
177
+ - Should be removed or investigated
178
+ - No references found in production or test code
179
+
180
+ - **[WARNING]** - Items with TODO comments (yellow in VS Code)
181
+ - Indicates planned but not yet implemented features
182
+ - Helps track technical debt
183
+
184
+ - **[INFO]** - Used only in tests (blue in VS Code)
185
+ - Testing helpers, mocks, or test-specific utilities
186
+ - Defined outside test files but only referenced from tests
187
+ - Not necessarily a problem, but good to know
188
+
189
+ ## How It Works
190
+
191
+ 1. **Project Loading**: Uses ts-morph to load the TypeScript project based on the provided tsconfig.json
192
+ 2. **Export Analysis**: For each exported declaration, finds all references across the codebase
193
+ 3. **Property Analysis**: For interfaces and type aliases, checks each property for external references
194
+ 4. **Reference Categorization**: Tracks references separately:
195
+ - Total references (definition + all usages)
196
+ - Test references (from test files)
197
+ - Non-test references (from production code)
198
+ 5. **Severity Assignment**:
199
+ - Items with TODO comments → WARNING
200
+ - Items only used in tests (nonTestReferences === 1 && testReferences > 0) → INFO
201
+ - Completely unused items → ERROR
202
+ 6. **Structural Equivalence Checking**: When a property has no direct references, searches for structurally equivalent properties (same name and type signature) across all interfaces and type aliases - if any equivalent property is used, all are considered used
203
+ 7. **File Exclusion**: Automatically filters out from being analyzed:
204
+ - Test files (files in `__tests__` directories or ending in `.test.ts` or `.spec.ts`)
205
+ - Files with `// @ts-nocheck` on the first line
206
+ - Note: Test files are still scanned for references to track test-only usage
207
+
208
+ ## VS Code Integration
209
+
210
+ Add this task to your `.vscode/tasks.json` to run the analyzer with problem matcher support:
211
+
212
+ ```json
213
+ {
214
+ "label": "Find Unused Exports",
215
+ "type": "shell",
216
+ "command": "ts-unused",
217
+ "args": ["./tsconfig.json"],
218
+ "presentation": {
219
+ "echo": true,
220
+ "reveal": "always",
221
+ "focus": false,
222
+ "panel": "shared"
223
+ },
224
+ "problemMatcher": {
225
+ "owner": "ts-unused",
226
+ "fileLocation": ["relative", "${workspaceFolder}"],
227
+ "pattern": [
228
+ {
229
+ "regexp": "^([^\\s].*?)$",
230
+ "file": 1
231
+ },
232
+ {
233
+ "regexp": "^\\s+(.+?):(\\d+):(\\d+)-(\\d+)\\s+\\[(ERROR|WARNING|INFO)\\]\\s+\\((.+)\\)$",
234
+ "code": 1,
235
+ "line": 2,
236
+ "column": 3,
237
+ "endColumn": 4,
238
+ "severity": 5,
239
+ "message": 6,
240
+ "loop": true
241
+ }
242
+ ]
243
+ }
244
+ }
245
+ ```
246
+
247
+ This will:
248
+ - Display results in VS Code's Problems panel
249
+ - Show severity indicators (error, warning, info)
250
+ - Allow clicking to navigate to the exact file location
251
+ - Highlight the entire identifier (function name, property name, etc.) in the editor
252
+ - Provide quick fixes and context
253
+
254
+ ## Advanced Features
255
+
256
+ ### Structural Property Equivalence
257
+
258
+ The analyzer handles cases where properties are re-declared across multiple interfaces with the same name and type. This commonly occurs with:
259
+
260
+ - **Interface Composition**: When spreading values between different interface types
261
+ - **Type Transformations**: When converting from one type to another via object spreading
262
+ - **Shared Property Patterns**: When multiple interfaces define the same property structure
263
+
264
+ **Example:**
265
+
266
+ ```typescript
267
+ // Source interface
268
+ interface SourceOptions {
269
+ timeout: number;
270
+ retryCount: number;
271
+ }
272
+
273
+ // Different interface with same properties
274
+ interface ProcessedOptions {
275
+ timeout: number; // Same name and type
276
+ retryCount: number; // Same name and type
277
+ additionalOption: string;
278
+ }
279
+
280
+ // Usage through type conversion
281
+ function handler(sourceOpts: SourceOptions) {
282
+ const processedOpts: ProcessedOptions = {
283
+ ...sourceOpts, // timeout and retryCount flow here
284
+ additionalOption: 'value'
285
+ };
286
+
287
+ console.log(processedOpts.timeout); // Accesses ProcessedOptions.timeout
288
+ }
289
+ ```
290
+
291
+ In this case, `SourceOptions.timeout` and `SourceOptions.retryCount` are **not** flagged as unused, even though they're never directly accessed. The analyzer recognizes that `ProcessedOptions.timeout` and `ProcessedOptions.retryCount` are structurally equivalent and used, so their counterparts in `SourceOptions` are also considered used.
292
+
293
+ ## License
294
+
295
+ MIT License
package/dist/cli.js ADDED
@@ -0,0 +1,915 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import fs2 from "node:fs";
5
+ import path10 from "node:path";
6
+
7
+ // src/analyzeProject.ts
8
+ import path6 from "node:path";
9
+ import { Project } from "ts-morph";
10
+
11
+ // src/findUnusedExports.ts
12
+ import path2 from "node:path";
13
+
14
+ // src/checkExportUsage.ts
15
+ import path from "node:path";
16
+ import { Node } from "ts-morph";
17
+ function getExportKind(declaration) {
18
+ if (Node.isFunctionDeclaration(declaration)) {
19
+ return "function";
20
+ }
21
+ if (Node.isClassDeclaration(declaration)) {
22
+ return "class";
23
+ }
24
+ if (Node.isInterfaceDeclaration(declaration)) {
25
+ return "interface";
26
+ }
27
+ if (Node.isTypeAliasDeclaration(declaration)) {
28
+ return "type";
29
+ }
30
+ if (Node.isEnumDeclaration(declaration)) {
31
+ return "enum";
32
+ }
33
+ if (Node.isModuleDeclaration(declaration)) {
34
+ return "namespace";
35
+ }
36
+ if (Node.isVariableDeclaration(declaration)) {
37
+ const parent = declaration.getParent();
38
+ if (Node.isVariableDeclarationList(parent)) {
39
+ const declarationKind = parent.getDeclarationKind();
40
+ if (declarationKind === "const") {
41
+ return "const";
42
+ }
43
+ }
44
+ return "variable";
45
+ }
46
+ return "export";
47
+ }
48
+ function getNameNode(declaration) {
49
+ if (Node.isFunctionDeclaration(declaration) || Node.isClassDeclaration(declaration) || Node.isInterfaceDeclaration(declaration) || Node.isTypeAliasDeclaration(declaration) || Node.isEnumDeclaration(declaration) || Node.isVariableDeclaration(declaration)) {
50
+ return declaration.getNameNode();
51
+ }
52
+ return;
53
+ }
54
+ function checkExportUsage(exportName, declarations, sourceFile, tsConfigDir, isTestFile) {
55
+ const firstDeclaration = declarations[0];
56
+ if (!firstDeclaration) {
57
+ return null;
58
+ }
59
+ const declarationSourceFile = firstDeclaration.getSourceFile();
60
+ if (declarationSourceFile.getFilePath() !== sourceFile.getFilePath()) {
61
+ return null;
62
+ }
63
+ if (!Node.isReferenceFindable(firstDeclaration)) {
64
+ return null;
65
+ }
66
+ const references = firstDeclaration.findReferences();
67
+ let totalReferences = 0;
68
+ let testReferences = 0;
69
+ let nonTestReferences = 0;
70
+ for (const refGroup of references) {
71
+ const refs = refGroup.getReferences();
72
+ for (const ref of refs) {
73
+ const refSourceFile = ref.getSourceFile();
74
+ totalReferences++;
75
+ if (isTestFile(refSourceFile)) {
76
+ testReferences++;
77
+ } else {
78
+ nonTestReferences++;
79
+ }
80
+ }
81
+ }
82
+ const onlyUsedInTests = nonTestReferences === 1 && testReferences > 0;
83
+ if (totalReferences > 1 && !onlyUsedInTests) {
84
+ return null;
85
+ }
86
+ const kind = getExportKind(firstDeclaration);
87
+ const relativePath = path.relative(tsConfigDir, sourceFile.getFilePath());
88
+ const severity = onlyUsedInTests ? "info" : "error";
89
+ const nameNode = getNameNode(firstDeclaration);
90
+ const positionNode = nameNode || firstDeclaration;
91
+ const startPos = positionNode.getStart();
92
+ const lineStartPos = positionNode.getStartLinePos();
93
+ const character = startPos - lineStartPos + 1;
94
+ const endCharacter = character + exportName.length;
95
+ const result = {
96
+ filePath: relativePath,
97
+ exportName,
98
+ line: firstDeclaration.getStartLineNumber(),
99
+ character,
100
+ endCharacter,
101
+ kind,
102
+ severity,
103
+ onlyUsedInTests
104
+ };
105
+ return result;
106
+ }
107
+
108
+ // src/hasNoCheck.ts
109
+ function hasNoCheck(sourceFile) {
110
+ const fullText = sourceFile.getFullText();
111
+ const firstLine = fullText.split(`
112
+ `)[0];
113
+ if (!firstLine) {
114
+ return false;
115
+ }
116
+ return firstLine.trim() === "// @ts-nocheck";
117
+ }
118
+
119
+ // src/findUnusedExports.ts
120
+ function findUnusedExports(project, tsConfigDir, isTestFile, onProgress, targetFilePath) {
121
+ const results = [];
122
+ for (const sourceFile of project.getSourceFiles()) {
123
+ if (isTestFile(sourceFile)) {
124
+ continue;
125
+ }
126
+ if (hasNoCheck(sourceFile)) {
127
+ continue;
128
+ }
129
+ if (targetFilePath && sourceFile.getFilePath() !== targetFilePath) {
130
+ continue;
131
+ }
132
+ if (onProgress) {
133
+ const relativePath = path2.relative(tsConfigDir, sourceFile.getFilePath());
134
+ onProgress(relativePath);
135
+ }
136
+ const exports = sourceFile.getExportedDeclarations();
137
+ for (const [exportName, declarations] of exports.entries()) {
138
+ const unusedExport = checkExportUsage(exportName, declarations, sourceFile, tsConfigDir, isTestFile);
139
+ if (unusedExport) {
140
+ results.push(unusedExport);
141
+ }
142
+ }
143
+ }
144
+ return results;
145
+ }
146
+
147
+ // src/findUnusedProperties.ts
148
+ import path5 from "node:path";
149
+
150
+ // src/analyzeInterfaces.ts
151
+ import path3 from "node:path";
152
+
153
+ // src/extractTodoComment.ts
154
+ function extractTodoComment(prop) {
155
+ const leadingComments = prop.getLeadingCommentRanges();
156
+ for (const comment of leadingComments) {
157
+ const commentText = comment.getText();
158
+ const singleLineMatch = commentText.match(/\/\/\s*TODO\s+(.+)/i);
159
+ if (singleLineMatch) {
160
+ const todoText = singleLineMatch[1];
161
+ if (todoText) {
162
+ return todoText.trim();
163
+ }
164
+ }
165
+ const multiLineMatch = commentText.match(/\/\*+\s*TODO\s+(.+?)\*\//is);
166
+ if (multiLineMatch) {
167
+ const todoText = multiLineMatch[1];
168
+ if (todoText) {
169
+ return todoText.trim().replace(/\s+/g, " ");
170
+ }
171
+ }
172
+ }
173
+ return;
174
+ }
175
+
176
+ // src/findStructurallyEquivalentProperties.ts
177
+ import { SyntaxKind } from "ts-morph";
178
+ function checkInterfaceProperties(iface, propName, propType, originalProp, equivalentProps) {
179
+ const properties = iface.getProperties();
180
+ for (const p of properties) {
181
+ if (p === originalProp) {
182
+ continue;
183
+ }
184
+ if (p.getName() === propName) {
185
+ const pType = p.getType().getText();
186
+ if (pType === propType) {
187
+ equivalentProps.push(p);
188
+ }
189
+ }
190
+ }
191
+ }
192
+ function checkTypeAliasProperties(typeAlias, propName, propType, originalProp, equivalentProps) {
193
+ const typeNode = typeAlias.getTypeNode();
194
+ if (!typeNode || typeNode.getKind() !== SyntaxKind.TypeLiteral) {
195
+ return;
196
+ }
197
+ const properties = typeNode.getChildren().filter((child) => child.getKind() === SyntaxKind.PropertySignature);
198
+ for (const p of properties) {
199
+ if (p === originalProp) {
200
+ continue;
201
+ }
202
+ if (p.getKind() === SyntaxKind.PropertySignature && p.getName() === propName) {
203
+ const pType = p.getType().getText();
204
+ if (pType === propType) {
205
+ equivalentProps.push(p);
206
+ }
207
+ }
208
+ }
209
+ }
210
+ function findStructurallyEquivalentProperties(prop, project) {
211
+ const propName = prop.getName();
212
+ const propType = prop.getType().getText();
213
+ const equivalentProps = [];
214
+ const sourceFiles = project.getSourceFiles();
215
+ for (const sourceFile of sourceFiles) {
216
+ const interfaces = sourceFile.getInterfaces();
217
+ for (const iface of interfaces) {
218
+ checkInterfaceProperties(iface, propName, propType, prop, equivalentProps);
219
+ }
220
+ const typeAliases = sourceFile.getTypeAliases();
221
+ for (const typeAlias of typeAliases) {
222
+ checkTypeAliasProperties(typeAlias, propName, propType, prop, equivalentProps);
223
+ }
224
+ }
225
+ return equivalentProps;
226
+ }
227
+
228
+ // src/isPropertyUnused.ts
229
+ function countPropertyReferences(prop, isTestFile) {
230
+ const references = prop.findReferences();
231
+ let totalReferences = 0;
232
+ let testReferences = 0;
233
+ let nonTestReferences = 0;
234
+ for (const refGroup of references) {
235
+ const refs = refGroup.getReferences();
236
+ for (const ref of refs) {
237
+ const refSourceFile = ref.getSourceFile();
238
+ totalReferences++;
239
+ if (isTestFile(refSourceFile)) {
240
+ testReferences++;
241
+ } else {
242
+ nonTestReferences++;
243
+ }
244
+ }
245
+ }
246
+ const result = {
247
+ totalReferences,
248
+ testReferences,
249
+ nonTestReferences
250
+ };
251
+ return result;
252
+ }
253
+ function isPropertyUnused(prop, isTestFile, project) {
254
+ const counts = countPropertyReferences(prop, isTestFile);
255
+ const onlyUsedInTests = counts.nonTestReferences === 1 && counts.testReferences > 0;
256
+ if (counts.totalReferences > 1 && !onlyUsedInTests) {
257
+ const result2 = { isUnusedOrTestOnly: false, onlyUsedInTests: false };
258
+ return result2;
259
+ }
260
+ const equivalentProps = findStructurallyEquivalentProperties(prop, project);
261
+ for (const equivalentProp of equivalentProps) {
262
+ const equivalentCounts = countPropertyReferences(equivalentProp, isTestFile);
263
+ const equivalentOnlyUsedInTests = equivalentCounts.nonTestReferences === 1 && equivalentCounts.testReferences > 0;
264
+ if (equivalentCounts.totalReferences > 1 && !equivalentOnlyUsedInTests) {
265
+ const result2 = { isUnusedOrTestOnly: false, onlyUsedInTests: false };
266
+ return result2;
267
+ }
268
+ }
269
+ const result = { isUnusedOrTestOnly: true, onlyUsedInTests };
270
+ return result;
271
+ }
272
+
273
+ // src/analyzeInterfaces.ts
274
+ function analyzeInterfaces(sourceFile, tsConfigDir, isTestFile, results, project) {
275
+ const interfaces = sourceFile.getInterfaces();
276
+ for (const iface of interfaces) {
277
+ const interfaceName = iface.getName();
278
+ for (const prop of iface.getProperties()) {
279
+ const usage = isPropertyUnused(prop, isTestFile, project);
280
+ if (usage.isUnusedOrTestOnly) {
281
+ const relativePath = path3.relative(tsConfigDir, sourceFile.getFilePath());
282
+ const todoComment = extractTodoComment(prop);
283
+ let severity = "error";
284
+ if (todoComment) {
285
+ severity = "warning";
286
+ } else if (usage.onlyUsedInTests) {
287
+ severity = "info";
288
+ }
289
+ const propertyName = prop.getName();
290
+ const startPos = prop.getStart();
291
+ const lineStartPos = prop.getStartLinePos();
292
+ const character = startPos - lineStartPos + 1;
293
+ const endCharacter = character + propertyName.length;
294
+ const result = {
295
+ filePath: relativePath,
296
+ typeName: interfaceName,
297
+ propertyName,
298
+ line: prop.getStartLineNumber(),
299
+ character,
300
+ endCharacter,
301
+ todoComment,
302
+ severity,
303
+ onlyUsedInTests: usage.onlyUsedInTests
304
+ };
305
+ results.push(result);
306
+ }
307
+ }
308
+ }
309
+ }
310
+
311
+ // src/analyzeTypeAliases.ts
312
+ import path4 from "node:path";
313
+ import { Node as Node2 } from "ts-morph";
314
+ function analyzeTypeLiteralMember(member, typeName, sourceFile, tsConfigDir, isTestFile, results, project) {
315
+ if (!Node2.isPropertySignature(member)) {
316
+ return;
317
+ }
318
+ const usage = isPropertyUnused(member, isTestFile, project);
319
+ if (!usage.isUnusedOrTestOnly) {
320
+ return;
321
+ }
322
+ const relativePath = path4.relative(tsConfigDir, sourceFile.getFilePath());
323
+ const todoComment = extractTodoComment(member);
324
+ let severity = "error";
325
+ if (todoComment) {
326
+ severity = "warning";
327
+ } else if (usage.onlyUsedInTests) {
328
+ severity = "info";
329
+ }
330
+ const propertyName = member.getName();
331
+ const startPos = member.getStart();
332
+ const lineStartPos = member.getStartLinePos();
333
+ const character = startPos - lineStartPos + 1;
334
+ const endCharacter = character + propertyName.length;
335
+ const result = {
336
+ filePath: relativePath,
337
+ typeName,
338
+ propertyName,
339
+ line: member.getStartLineNumber(),
340
+ character,
341
+ endCharacter,
342
+ todoComment,
343
+ severity,
344
+ onlyUsedInTests: usage.onlyUsedInTests
345
+ };
346
+ results.push(result);
347
+ }
348
+ function analyzeTypeAlias(typeAlias, sourceFile, tsConfigDir, isTestFile, results, project) {
349
+ const typeName = typeAlias.getName();
350
+ const typeNode = typeAlias.getTypeNode();
351
+ if (!typeNode) {
352
+ return;
353
+ }
354
+ if (!Node2.isTypeLiteral(typeNode)) {
355
+ return;
356
+ }
357
+ for (const member of typeNode.getMembers()) {
358
+ analyzeTypeLiteralMember(member, typeName, sourceFile, tsConfigDir, isTestFile, results, project);
359
+ }
360
+ }
361
+ function analyzeTypeAliases(sourceFile, tsConfigDir, isTestFile, results, project) {
362
+ const typeAliases = sourceFile.getTypeAliases();
363
+ for (const typeAlias of typeAliases) {
364
+ analyzeTypeAlias(typeAlias, sourceFile, tsConfigDir, isTestFile, results, project);
365
+ }
366
+ }
367
+
368
+ // src/findUnusedProperties.ts
369
+ function findUnusedProperties(project, tsConfigDir, isTestFile, onProgress, targetFilePath) {
370
+ const results = [];
371
+ for (const sourceFile of project.getSourceFiles()) {
372
+ if (isTestFile(sourceFile)) {
373
+ continue;
374
+ }
375
+ if (hasNoCheck(sourceFile)) {
376
+ continue;
377
+ }
378
+ if (targetFilePath && sourceFile.getFilePath() !== targetFilePath) {
379
+ continue;
380
+ }
381
+ if (onProgress) {
382
+ const relativePath = path5.relative(tsConfigDir, sourceFile.getFilePath());
383
+ onProgress(relativePath);
384
+ }
385
+ analyzeInterfaces(sourceFile, tsConfigDir, isTestFile, results, project);
386
+ analyzeTypeAliases(sourceFile, tsConfigDir, isTestFile, results, project);
387
+ }
388
+ return results;
389
+ }
390
+
391
+ // src/isTestFile.ts
392
+ var TEST_FILE_EXTENSIONS = [".test.ts", ".test.tsx", ".spec.ts", ".spec.tsx"];
393
+ function isTestFile(sourceFile) {
394
+ const filePath = sourceFile.getFilePath();
395
+ return filePath.includes("__tests__") || TEST_FILE_EXTENSIONS.some((ext) => filePath.endsWith(ext));
396
+ }
397
+
398
+ // src/analyzeProject.ts
399
+ function analyzeProject(tsConfigPath, onProgress, targetFilePath, isTestFile2 = isTestFile) {
400
+ const project = new Project({
401
+ tsConfigFilePath: tsConfigPath
402
+ });
403
+ const tsConfigDir = path6.dirname(tsConfigPath);
404
+ const allSourceFiles = project.getSourceFiles();
405
+ const filesToAnalyze = allSourceFiles.filter((sf) => {
406
+ if (isTestFile2(sf)) {
407
+ return false;
408
+ }
409
+ if (hasNoCheck(sf)) {
410
+ return false;
411
+ }
412
+ if (targetFilePath && sf.getFilePath() !== targetFilePath) {
413
+ return false;
414
+ }
415
+ return true;
416
+ });
417
+ const totalFiles = filesToAnalyze.length;
418
+ let currentFile = 0;
419
+ const filesProcessed = new Set;
420
+ const progressCallback = onProgress ? (filePath) => {
421
+ if (!filesProcessed.has(filePath)) {
422
+ filesProcessed.add(filePath);
423
+ currentFile++;
424
+ onProgress(currentFile, totalFiles, filePath);
425
+ }
426
+ } : undefined;
427
+ const unusedExports = findUnusedExports(project, tsConfigDir, isTestFile2, progressCallback, targetFilePath);
428
+ const unusedProperties = findUnusedProperties(project, tsConfigDir, isTestFile2, progressCallback, targetFilePath);
429
+ const unusedFiles = [];
430
+ const fileExportCounts = new Map;
431
+ for (const sourceFile of filesToAnalyze) {
432
+ const filePath = path6.relative(tsConfigDir, sourceFile.getFilePath());
433
+ const exports = sourceFile.getExportedDeclarations();
434
+ const totalExports = exports.size;
435
+ if (totalExports > 0) {
436
+ fileExportCounts.set(filePath, { total: totalExports, unused: 0 });
437
+ }
438
+ }
439
+ for (const unusedExport of unusedExports) {
440
+ const counts = fileExportCounts.get(unusedExport.filePath);
441
+ if (counts) {
442
+ counts.unused++;
443
+ }
444
+ }
445
+ for (const [filePath, counts] of fileExportCounts.entries()) {
446
+ if (counts.total > 0 && counts.unused === counts.total) {
447
+ unusedFiles.push(filePath);
448
+ }
449
+ }
450
+ const results = {
451
+ unusedExports,
452
+ unusedProperties,
453
+ unusedFiles
454
+ };
455
+ return results;
456
+ }
457
+
458
+ // src/fixProject.ts
459
+ import fs from "node:fs";
460
+ import path8 from "node:path";
461
+ import { Project as Project2, SyntaxKind as SyntaxKind2 } from "ts-morph";
462
+
463
+ // src/checkGitStatus.ts
464
+ import { execSync } from "node:child_process";
465
+ import path7 from "node:path";
466
+ function checkGitStatus(workingDir) {
467
+ const changedFiles = new Set;
468
+ try {
469
+ const gitRoot = execSync("git rev-parse --show-toplevel", {
470
+ cwd: workingDir,
471
+ encoding: "utf-8",
472
+ stdio: ["pipe", "pipe", "ignore"]
473
+ }).trim();
474
+ const output = execSync("git status --porcelain", {
475
+ cwd: workingDir,
476
+ encoding: "utf-8",
477
+ stdio: ["pipe", "pipe", "ignore"]
478
+ });
479
+ const lines = output.trim().split(`
480
+ `);
481
+ for (const line of lines) {
482
+ if (!line)
483
+ continue;
484
+ const filename = line.slice(3).trim();
485
+ const actualFilename = filename.includes(" -> ") ? filename.split(" -> ")[1] : filename;
486
+ if (actualFilename) {
487
+ const absolutePath = path7.resolve(gitRoot, actualFilename);
488
+ changedFiles.add(absolutePath);
489
+ }
490
+ }
491
+ } catch (_error) {
492
+ return changedFiles;
493
+ }
494
+ return changedFiles;
495
+ }
496
+
497
+ // src/fixProject.ts
498
+ function fixProject(tsConfigPath, onProgress, isTestFile2 = isTestFile) {
499
+ const results = {
500
+ fixedExports: 0,
501
+ fixedProperties: 0,
502
+ deletedFiles: 0,
503
+ skippedFiles: [],
504
+ errors: []
505
+ };
506
+ const analysis = analyzeProject(tsConfigPath, undefined, undefined, isTestFile2);
507
+ const tsConfigDir = path8.dirname(path8.resolve(tsConfigPath));
508
+ const filesWithChanges = checkGitStatus(tsConfigDir);
509
+ const project = new Project2({
510
+ tsConfigFilePath: tsConfigPath
511
+ });
512
+ const deletedFiles = new Set;
513
+ for (const relativeFilePath of analysis.unusedFiles) {
514
+ const absoluteFilePath = path8.resolve(tsConfigDir, relativeFilePath);
515
+ if (filesWithChanges.has(absoluteFilePath)) {
516
+ onProgress?.(`Skipped: ${relativeFilePath} (has local git changes)`);
517
+ results.skippedFiles.push(relativeFilePath);
518
+ continue;
519
+ }
520
+ try {
521
+ onProgress?.(`Deleting: ${relativeFilePath} (all exports unused)`);
522
+ fs.unlinkSync(absoluteFilePath);
523
+ deletedFiles.add(relativeFilePath);
524
+ results.deletedFiles++;
525
+ } catch (error) {
526
+ const errorMessage = error instanceof Error ? error.message : String(error);
527
+ results.errors.push({ file: relativeFilePath, error: errorMessage });
528
+ onProgress?.(`Error deleting ${relativeFilePath}: ${errorMessage}`);
529
+ }
530
+ }
531
+ if (deletedFiles.size > 0) {
532
+ cleanupBrokenImports(project, tsConfigDir, deletedFiles, filesWithChanges, onProgress, results);
533
+ }
534
+ const exportsByFile = new Map;
535
+ for (const unusedExport of analysis.unusedExports) {
536
+ if (!exportsByFile.has(unusedExport.filePath)) {
537
+ exportsByFile.set(unusedExport.filePath, []);
538
+ }
539
+ exportsByFile.get(unusedExport.filePath)?.push(unusedExport);
540
+ }
541
+ for (const [relativeFilePath, exports] of exportsByFile.entries()) {
542
+ const absoluteFilePath = path8.resolve(tsConfigDir, relativeFilePath);
543
+ if (analysis.unusedFiles.includes(relativeFilePath)) {
544
+ continue;
545
+ }
546
+ if (filesWithChanges.has(absoluteFilePath)) {
547
+ onProgress?.(`Skipped: ${relativeFilePath} (has local git changes)`);
548
+ results.skippedFiles.push(relativeFilePath);
549
+ continue;
550
+ }
551
+ try {
552
+ const sourceFile = project.getSourceFile(absoluteFilePath);
553
+ if (!sourceFile) {
554
+ continue;
555
+ }
556
+ onProgress?.(`Fixing: ${relativeFilePath}`);
557
+ for (const unusedExport of exports) {
558
+ if (removeExport(sourceFile, unusedExport.exportName)) {
559
+ onProgress?.(` ✓ Removed unused export: ${unusedExport.exportName}`);
560
+ results.fixedExports++;
561
+ }
562
+ }
563
+ sourceFile.saveSync();
564
+ } catch (error) {
565
+ const errorMessage = error instanceof Error ? error.message : String(error);
566
+ results.errors.push({ file: relativeFilePath, error: errorMessage });
567
+ onProgress?.(`Error fixing ${relativeFilePath}: ${errorMessage}`);
568
+ }
569
+ }
570
+ const propertiesByFile = new Map;
571
+ for (const unusedProperty of analysis.unusedProperties) {
572
+ if (!propertiesByFile.has(unusedProperty.filePath)) {
573
+ propertiesByFile.set(unusedProperty.filePath, []);
574
+ }
575
+ propertiesByFile.get(unusedProperty.filePath)?.push(unusedProperty);
576
+ }
577
+ for (const [relativeFilePath, properties] of propertiesByFile.entries()) {
578
+ const absoluteFilePath = path8.resolve(tsConfigDir, relativeFilePath);
579
+ if (analysis.unusedFiles.includes(relativeFilePath)) {
580
+ continue;
581
+ }
582
+ if (filesWithChanges.has(absoluteFilePath)) {
583
+ if (!results.skippedFiles.includes(relativeFilePath)) {
584
+ onProgress?.(`Skipped: ${relativeFilePath} (has local git changes)`);
585
+ results.skippedFiles.push(relativeFilePath);
586
+ }
587
+ continue;
588
+ }
589
+ try {
590
+ const sourceFile = project.getSourceFile(absoluteFilePath);
591
+ if (!sourceFile) {
592
+ continue;
593
+ }
594
+ if (!exportsByFile.has(relativeFilePath)) {
595
+ onProgress?.(`Fixing: ${relativeFilePath}`);
596
+ }
597
+ for (const unusedProperty of properties) {
598
+ if (removeProperty(sourceFile, unusedProperty.typeName, unusedProperty.propertyName)) {
599
+ onProgress?.(` ✓ Removed unused property: ${unusedProperty.typeName}.${unusedProperty.propertyName}`);
600
+ results.fixedProperties++;
601
+ }
602
+ }
603
+ sourceFile.saveSync();
604
+ } catch (error) {
605
+ const errorMessage = error instanceof Error ? error.message : String(error);
606
+ results.errors.push({ file: relativeFilePath, error: errorMessage });
607
+ onProgress?.(`Error fixing ${relativeFilePath}: ${errorMessage}`);
608
+ }
609
+ }
610
+ return results;
611
+ }
612
+ function removeExport(sourceFile, exportName) {
613
+ const exportedDeclarations = sourceFile.getExportedDeclarations();
614
+ const declarations = exportedDeclarations.get(exportName);
615
+ if (!declarations || declarations.length === 0) {
616
+ return false;
617
+ }
618
+ for (const declaration of declarations) {
619
+ if (declaration.getKind() === SyntaxKind2.VariableDeclaration) {
620
+ const variableStatement = declaration.getFirstAncestorByKind(SyntaxKind2.VariableStatement);
621
+ if (variableStatement) {
622
+ variableStatement.remove();
623
+ continue;
624
+ }
625
+ }
626
+ if ("remove" in declaration && typeof declaration.remove === "function") {
627
+ declaration.remove();
628
+ }
629
+ }
630
+ return true;
631
+ }
632
+ function removeProperty(sourceFile, typeName, propertyName) {
633
+ const interfaces = sourceFile.getInterfaces();
634
+ for (const iface of interfaces) {
635
+ if (iface.getName() === typeName) {
636
+ const property = iface.getProperty(propertyName);
637
+ if (property) {
638
+ property.remove();
639
+ return true;
640
+ }
641
+ }
642
+ }
643
+ const typeAliases = sourceFile.getTypeAliases();
644
+ for (const typeAlias of typeAliases) {
645
+ if (typeAlias.getName() === typeName) {
646
+ const typeNode = typeAlias.getTypeNode();
647
+ if (typeNode && typeNode.getKind() === SyntaxKind2.TypeLiteral) {
648
+ const typeLiteral = typeNode.asKindOrThrow(SyntaxKind2.TypeLiteral);
649
+ const members = typeLiteral.getMembers();
650
+ for (const member of members) {
651
+ if (member.getKind() === SyntaxKind2.PropertySignature) {
652
+ const propSig = member.asKind(SyntaxKind2.PropertySignature);
653
+ if (propSig?.getName() === propertyName) {
654
+ propSig.remove();
655
+ return true;
656
+ }
657
+ }
658
+ }
659
+ }
660
+ }
661
+ }
662
+ return false;
663
+ }
664
+ function cleanupBrokenImports(project, tsConfigDir, deletedFiles, filesWithChanges, onProgress, results) {
665
+ const sourceFiles = project.getSourceFiles();
666
+ for (const sourceFile of sourceFiles) {
667
+ const relativePath = path8.relative(tsConfigDir, sourceFile.getFilePath());
668
+ const absolutePath = sourceFile.getFilePath();
669
+ if (filesWithChanges.has(absolutePath)) {
670
+ continue;
671
+ }
672
+ const exportDeclarations = sourceFile.getExportDeclarations();
673
+ let hasValidExports = false;
674
+ const brokenExports = [];
675
+ for (const exportDecl of exportDeclarations) {
676
+ const moduleSpecifier = exportDecl.getModuleSpecifierValue();
677
+ if (moduleSpecifier) {
678
+ const resolvedPath = resolveImportPath(sourceFile.getFilePath(), moduleSpecifier);
679
+ const relativeResolvedPath = resolvedPath ? path8.relative(tsConfigDir, resolvedPath) : null;
680
+ if (relativeResolvedPath && deletedFiles.has(relativeResolvedPath)) {
681
+ brokenExports.push(exportDecl);
682
+ } else {
683
+ hasValidExports = true;
684
+ }
685
+ } else {
686
+ hasValidExports = true;
687
+ }
688
+ }
689
+ const hasOwnDeclarations = sourceFile.getFunctions().length > 0 || sourceFile.getClasses().length > 0 || sourceFile.getInterfaces().length > 0 || sourceFile.getTypeAliases().length > 0 || sourceFile.getVariableDeclarations().length > 0 || sourceFile.getEnums().length > 0;
690
+ if (hasOwnDeclarations) {
691
+ hasValidExports = true;
692
+ }
693
+ if (brokenExports.length > 0 && !hasValidExports) {
694
+ try {
695
+ onProgress?.(`Deleting: ${relativePath} (only re-exports from deleted files)`);
696
+ fs.unlinkSync(absolutePath);
697
+ if (results) {
698
+ results.deletedFiles++;
699
+ }
700
+ } catch (error) {
701
+ const errorMessage = error instanceof Error ? error.message : String(error);
702
+ if (results) {
703
+ results.errors.push({ file: relativePath, error: errorMessage });
704
+ }
705
+ onProgress?.(`Error deleting ${relativePath}: ${errorMessage}`);
706
+ }
707
+ } else if (brokenExports.length > 0) {
708
+ try {
709
+ onProgress?.(`Fixing: ${relativePath}`);
710
+ for (const exportDecl of brokenExports) {
711
+ const moduleSpec = exportDecl.getModuleSpecifierValue();
712
+ exportDecl.remove();
713
+ onProgress?.(` ✓ Removed broken re-export from: ${moduleSpec}`);
714
+ }
715
+ sourceFile.saveSync();
716
+ } catch (error) {
717
+ const errorMessage = error instanceof Error ? error.message : String(error);
718
+ if (results) {
719
+ results.errors.push({ file: relativePath, error: errorMessage });
720
+ }
721
+ onProgress?.(`Error fixing ${relativePath}: ${errorMessage}`);
722
+ }
723
+ }
724
+ }
725
+ }
726
+ function resolveImportPath(fromFile, importPath) {
727
+ const fromDir = path8.dirname(fromFile);
728
+ if (importPath.startsWith(".")) {
729
+ const resolved = path8.resolve(fromDir, importPath);
730
+ const extensions = [".ts", ".tsx", ".js", ".jsx"];
731
+ for (const ext of extensions) {
732
+ const withExt = resolved + ext;
733
+ if (fs.existsSync(withExt)) {
734
+ return withExt;
735
+ }
736
+ }
737
+ for (const ext of extensions) {
738
+ const indexFile = path8.join(resolved, `index${ext}`);
739
+ if (fs.existsSync(indexFile)) {
740
+ return indexFile;
741
+ }
742
+ }
743
+ return `${resolved}.ts`;
744
+ }
745
+ return null;
746
+ }
747
+
748
+ // src/formatResults.ts
749
+ import path9 from "node:path";
750
+ function getSeverityMarker(severity) {
751
+ const markers = {
752
+ error: "[ERROR]",
753
+ warning: "[WARNING]",
754
+ info: "[INFO]"
755
+ };
756
+ return markers[severity];
757
+ }
758
+ function formatExportLine(item) {
759
+ const marker = getSeverityMarker(item.severity);
760
+ if (item.onlyUsedInTests) {
761
+ return ` ${item.exportName}:${item.line}:${item.character}-${item.endCharacter} ${marker} (Used only in tests)`;
762
+ }
763
+ return ` ${item.exportName}:${item.line}:${item.character}-${item.endCharacter} ${marker} (Unused ${item.kind})`;
764
+ }
765
+ function formatPropertyLine(item) {
766
+ const status = item.onlyUsedInTests ? "Used only in tests" : "Unused property";
767
+ const todoSuffix = item.todoComment ? `: [TODO] ${item.todoComment}` : "";
768
+ const marker = getSeverityMarker(item.severity);
769
+ return ` ${item.typeName}.${item.propertyName}:${item.line}:${item.character}-${item.endCharacter} ${marker} (${status}${todoSuffix})`;
770
+ }
771
+ function formatGroupedItems(items, formatter, tsConfigDir, cwd) {
772
+ const lines = [];
773
+ const grouped = groupByFile(items);
774
+ for (const [filePath, groupItems] of grouped.entries()) {
775
+ const absolutePath = path9.resolve(tsConfigDir, filePath);
776
+ const relativePath = path9.relative(cwd, absolutePath);
777
+ lines.push(relativePath);
778
+ for (const item of groupItems) {
779
+ lines.push(formatter(item));
780
+ }
781
+ lines.push("");
782
+ }
783
+ return lines;
784
+ }
785
+ function formatResults(results, tsConfigDir) {
786
+ const lines = [];
787
+ const cwd = process.cwd();
788
+ const unusedExportNames = new Set;
789
+ for (const exportItem of results.unusedExports) {
790
+ unusedExportNames.add(exportItem.exportName);
791
+ }
792
+ const propertiesToReport = results.unusedProperties.filter((prop) => !unusedExportNames.has(prop.typeName));
793
+ const unusedFileSet = new Set(results.unusedFiles);
794
+ const exportsToReport = results.unusedExports.filter((exp) => !unusedFileSet.has(exp.filePath));
795
+ if (results.unusedFiles.length > 0) {
796
+ lines.push("Completely Unused Files:");
797
+ lines.push("");
798
+ for (const filePath of results.unusedFiles) {
799
+ const absolutePath = path9.resolve(tsConfigDir, filePath);
800
+ const relativePath = path9.relative(cwd, absolutePath);
801
+ lines.push(relativePath);
802
+ lines.push(" file:1:1-1 [ERROR] (All exports unused - file can be deleted)");
803
+ lines.push("");
804
+ }
805
+ }
806
+ if (exportsToReport.length > 0) {
807
+ lines.push("Unused Exports:");
808
+ lines.push("");
809
+ lines.push(...formatGroupedItems(exportsToReport, formatExportLine, tsConfigDir, cwd));
810
+ }
811
+ if (propertiesToReport.length > 0) {
812
+ lines.push("Unused Type/Interface Properties:");
813
+ lines.push("");
814
+ lines.push(...formatGroupedItems(propertiesToReport, formatPropertyLine, tsConfigDir, cwd));
815
+ }
816
+ if (results.unusedFiles.length === 0 && exportsToReport.length === 0 && propertiesToReport.length === 0) {
817
+ lines.push("No unused exports or properties found!");
818
+ } else {
819
+ lines.push("Summary:");
820
+ lines.push(` Completely unused files: ${results.unusedFiles.length}`);
821
+ lines.push(` Unused exports: ${exportsToReport.length}`);
822
+ lines.push(` Unused properties: ${propertiesToReport.length}`);
823
+ }
824
+ return lines.join(`
825
+ `);
826
+ }
827
+ function groupByFile(items) {
828
+ const grouped = new Map;
829
+ for (const item of items) {
830
+ const existing = grouped.get(item.filePath);
831
+ if (existing) {
832
+ existing.push(item);
833
+ } else {
834
+ grouped.set(item.filePath, [item]);
835
+ }
836
+ }
837
+ return grouped;
838
+ }
839
+
840
+ // src/cli.ts
841
+ function main() {
842
+ const args = process.argv.slice(2);
843
+ if (args.length === 0) {
844
+ console.log("Usage: ts-unused <command> <path-to-tsconfig.json> [file-path-to-check]");
845
+ console.log("");
846
+ console.log("Commands:");
847
+ console.log(" check - Analyze and report unused exports/properties (default)");
848
+ console.log(" fix - Automatically remove unused exports/properties and delete unused files");
849
+ console.log("");
850
+ console.log("Examples:");
851
+ console.log(" ts-unused check tsconfig.json");
852
+ console.log(" ts-unused check ./project-dir # looks for tsconfig.json inside");
853
+ console.log(" ts-unused fix tsconfig.json");
854
+ process.exit(1);
855
+ }
856
+ const firstArg = args[0] ?? "";
857
+ let command = "check";
858
+ let configIndex = 0;
859
+ if (firstArg === "check" || firstArg === "fix") {
860
+ command = firstArg;
861
+ configIndex = 1;
862
+ }
863
+ let tsConfigPath = path10.resolve(args[configIndex] ?? "");
864
+ if (fs2.existsSync(tsConfigPath) && fs2.statSync(tsConfigPath).isDirectory()) {
865
+ tsConfigPath = path10.join(tsConfigPath, "tsconfig.json");
866
+ }
867
+ const targetFilePath = args[configIndex + 1] ? path10.resolve(args[configIndex + 1]) : undefined;
868
+ if (command === "fix") {
869
+ console.log(`Fixing TypeScript project: ${tsConfigPath}`);
870
+ console.log("");
871
+ const results = fixProject(tsConfigPath, (message) => {
872
+ console.log(message);
873
+ });
874
+ console.log("");
875
+ console.log("Summary:");
876
+ console.log(` Fixed exports: ${results.fixedExports}`);
877
+ console.log(` Fixed properties: ${results.fixedProperties}`);
878
+ console.log(` Deleted files: ${results.deletedFiles}`);
879
+ console.log(` Skipped files: ${results.skippedFiles.length}`);
880
+ if (results.errors.length > 0) {
881
+ console.log("");
882
+ console.log("Errors:");
883
+ for (const error of results.errors) {
884
+ console.log(` ${error.file}: ${error.error}`);
885
+ }
886
+ }
887
+ if (results.skippedFiles.length > 0) {
888
+ console.log("");
889
+ console.log("Skipped files (have local git changes):");
890
+ for (const file of results.skippedFiles) {
891
+ console.log(` ${file}`);
892
+ }
893
+ }
894
+ } else {
895
+ console.log(`Analyzing TypeScript project: ${tsConfigPath}`);
896
+ if (targetFilePath) {
897
+ console.log(`Checking only: ${targetFilePath}`);
898
+ }
899
+ console.log("");
900
+ const results = analyzeProject(tsConfigPath, (current, total, filePath) => {
901
+ const percentage = Math.min(100, Math.floor(current / total * 100));
902
+ const barLength = 40;
903
+ const filledLength = Math.min(barLength, Math.max(0, Math.floor(current / total * barLength)));
904
+ const emptyLength = Math.max(0, barLength - filledLength);
905
+ const bar = "█".repeat(filledLength) + "░".repeat(emptyLength);
906
+ const fileName = path10.basename(filePath);
907
+ process.stdout.write(`\r\x1B[KProgress: [${bar}] ${percentage}% (${current}/${total}) ${fileName}`);
908
+ }, targetFilePath);
909
+ process.stdout.write("\r\x1B[K");
910
+ const tsConfigDir = path10.dirname(path10.resolve(tsConfigPath));
911
+ const output = formatResults(results, tsConfigDir);
912
+ console.log(output);
913
+ }
914
+ }
915
+ main();
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "ts-unused",
3
+ "version": "1.0.0",
4
+ "description": "Find unused exports and properties in TypeScript projects",
5
+ "license": "MIT",
6
+ "bin": {
7
+ "ts-unused": "dist/cli.js"
8
+ },
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "type": "module",
13
+ "scripts": {
14
+ "clean": "rm -rf dist",
15
+ "build:js": "bun build src/cli.ts --outdir dist --target node --external ts-morph --external typescript",
16
+ "build": "bun lint && bun test && bun clean && bun build:js && bun sanity-check --no-build",
17
+ "typecheck": "tsgo --noEmit",
18
+ "lint": "bun run fix && biome check src",
19
+ "fix": "biome check --write --unsafe src",
20
+ "sanity-check": "bun run scripts/sanity-check.ts"
21
+ },
22
+ "devDependencies": {
23
+ "@biomejs/biome": "^2.3.8",
24
+ "@types/bun": "latest",
25
+ "@types/node": "^24.10.1",
26
+ "@typescript/native-preview": "^7.0.0-dev.20251128.1"
27
+ },
28
+ "dependencies": {
29
+ "ts-morph": "^27.0.2",
30
+ "typescript": "^5"
31
+ }
32
+ }