ts-unused 1.0.2 → 1.0.3

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.
package/README.md CHANGED
@@ -104,6 +104,103 @@ ts-unused ./tsconfig.json
104
104
 
105
105
  # Auto-fix
106
106
  ts-unused fix ./tsconfig.json
107
+
108
+ # With custom config path
109
+ ts-unused ./tsconfig.json --config ./custom.config.ts
110
+ ```
111
+
112
+ ## Configuration
113
+
114
+ Create an `unused.config.ts` file in your project root (same directory as `tsconfig.json`) to customize the analysis behavior.
115
+
116
+ ### Example Configuration
117
+
118
+ ```typescript
119
+ import { defineConfig } from "ts-unused";
120
+
121
+ export default defineConfig({
122
+ // Custom patterns for identifying test files
123
+ testFilePatterns: [
124
+ "**/*.test.ts",
125
+ "**/*.test.tsx",
126
+ "**/*.spec.ts",
127
+ "**/*.spec.tsx",
128
+ "**/__tests__/**",
129
+ "**/Test*.ts", // Include files like TestLogger.ts
130
+ ],
131
+
132
+ // Files to completely ignore during analysis
133
+ ignoreFilePatterns: [
134
+ "**/generated/**",
135
+ "**/*.d.ts",
136
+ ],
137
+
138
+ // Export names to ignore (supports glob patterns)
139
+ ignoreExports: [
140
+ "formatLogMessage", // Specific export
141
+ "internal*", // All exports starting with "internal"
142
+ ],
143
+
144
+ // Property names to ignore in interfaces/types
145
+ ignoreProperties: [
146
+ "message", // Common error property
147
+ "_*", // Private-like properties
148
+ ],
149
+
150
+ // Type names to skip property analysis for
151
+ ignoreTypes: [
152
+ "*Config", // Skip all config types
153
+ "Options",
154
+ ],
155
+
156
+ // Whether to ignore module augmentation declarations
157
+ // (declare module "..." blocks)
158
+ ignoreModuleAugmentations: true,
159
+
160
+ // Toggle specific analysis features
161
+ analyzeExports: true,
162
+ analyzeProperties: true,
163
+ analyzeNeverReturnedTypes: true,
164
+ detectUnusedFiles: true,
165
+ });
166
+ ```
167
+
168
+ ### Configuration Options
169
+
170
+ | Option | Type | Default | Description |
171
+ | --- | --- | --- | --- |
172
+ | `testFilePatterns` | `string[]` | `["**/*.test.ts", "**/*.test.tsx", "**/*.spec.ts", "**/*.spec.tsx", "**/__tests__/**"]` | Glob patterns for test file detection |
173
+ | `ignoreFilePatterns` | `string[]` | `[]` | Files to completely ignore during analysis |
174
+ | `ignoreExports` | `string[]` | `[]` | Export names to ignore (supports glob patterns) |
175
+ | `ignoreProperties` | `string[]` | `[]` | Property names to ignore (supports glob patterns) |
176
+ | `ignoreTypes` | `string[]` | `[]` | Type names to skip property analysis for |
177
+ | `ignoreModuleAugmentations` | `boolean` | `true` | Whether to ignore `declare module` blocks |
178
+ | `analyzeExports` | `boolean` | `true` | Enable/disable unused export detection |
179
+ | `analyzeProperties` | `boolean` | `true` | Enable/disable unused property detection |
180
+ | `analyzeNeverReturnedTypes` | `boolean` | `true` | Enable/disable never-returned type detection |
181
+ | `detectUnusedFiles` | `boolean` | `true` | Enable/disable completely unused file detection |
182
+
183
+ ### Pattern Syntax
184
+
185
+ The configuration supports glob-like patterns:
186
+
187
+ - `*` - Matches any characters except `/`
188
+ - `**` - Matches any characters including `/`
189
+ - `?` - Matches any single character
190
+
191
+ Examples:
192
+ - `**/*.test.ts` - All `.test.ts` files in any directory
193
+ - `**/Test*.ts` - All files starting with `Test` and ending with `.ts`
194
+ - `internal*` - Names starting with `internal`
195
+ - `*Config` - Names ending with `Config`
196
+
197
+ ### CLI Options
198
+
199
+ ```bash
200
+ ts-unused [command] <path-to-tsconfig.json> [options]
201
+
202
+ Options:
203
+ --config, -c <path> Path to configuration file (default: unused.config.ts in project dir)
107
204
  ```
108
205
 
109
206
  ## Output
@@ -355,6 +452,179 @@ function handler(sourceOpts: SourceOptions) {
355
452
 
356
453
  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.
357
454
 
455
+ ## Programmatic API
456
+
457
+ ts-unused can be used as a library in your own tools and scripts.
458
+
459
+ ### Installation
460
+
461
+ ```bash
462
+ npm install ts-unused
463
+ # or
464
+ bun add ts-unused
465
+ ```
466
+
467
+ ### Basic Usage
468
+
469
+ ```typescript
470
+ import path from "node:path";
471
+ import { analyzeProject, loadConfigSync, formatResults } from "ts-unused";
472
+
473
+ // Given a project root with unused.config.ts
474
+ const projectRoot = "/path/to/your/project";
475
+ const tsConfigPath = path.join(projectRoot, "tsconfig.json");
476
+
477
+ // Load config from unused.config.ts (auto-detected in project directory)
478
+ const config = loadConfigSync(tsConfigPath);
479
+
480
+ // Analyze with config
481
+ const results = analyzeProject(tsConfigPath, undefined, undefined, { config });
482
+
483
+ // Format and print results (same output as CLI)
484
+ const output = formatResults(results, projectRoot);
485
+ console.log(output);
486
+ ```
487
+
488
+ ### API Reference
489
+
490
+ #### `analyzeProject(tsConfigPath, onProgress?, targetFilePath?, options?)`
491
+
492
+ Analyzes a TypeScript project for unused exports, properties, and never-returned types.
493
+
494
+ ```typescript
495
+ import { analyzeProject, type AnalysisResults, type AnalyzeProjectOptions } from "ts-unused";
496
+
497
+ const options: AnalyzeProjectOptions = {
498
+ config: {
499
+ ignoreFilePatterns: ["**/generated/**"],
500
+ ignoreExports: ["internal*"],
501
+ analyzeProperties: true,
502
+ },
503
+ };
504
+
505
+ const results: AnalysisResults = analyzeProject(
506
+ "./tsconfig.json",
507
+ (current, total, filePath) => console.log(`Processing ${current}/${total}: ${filePath}`),
508
+ undefined, // targetFilePath - analyze all files
509
+ options
510
+ );
511
+
512
+ console.log(`Found ${results.unusedExports.length} unused exports`);
513
+ console.log(`Found ${results.unusedProperties.length} unused properties`);
514
+ console.log(`Found ${results.unusedFiles.length} completely unused files`);
515
+ console.log(`Found ${results.neverReturnedTypes?.length ?? 0} never-returned types`);
516
+ ```
517
+
518
+ **Parameters:**
519
+ - `tsConfigPath` - Path to tsconfig.json
520
+ - `onProgress` - Optional callback for progress updates
521
+ - `targetFilePath` - Optional path to analyze a single file
522
+ - `options` - Optional `AnalyzeProjectOptions` object with `config` and/or `isTestFile`
523
+
524
+ **Returns:** `AnalysisResults` object with arrays of findings
525
+
526
+ #### `fixProject(tsConfigPath, onProgress?, isTestFile?)`
527
+
528
+ Automatically removes unused exports, properties, and deletes unused files.
529
+
530
+ ```typescript
531
+ import { fixProject, type FixResults } from "ts-unused";
532
+
533
+ const results: FixResults = fixProject(
534
+ "./tsconfig.json",
535
+ (message) => console.log(message)
536
+ );
537
+
538
+ console.log(`Fixed ${results.fixedExports} exports`);
539
+ console.log(`Fixed ${results.fixedProperties} properties`);
540
+ console.log(`Fixed ${results.fixedNeverReturnedTypes} never-returned types`);
541
+ console.log(`Deleted ${results.deletedFiles} files`);
542
+ console.log(`Skipped ${results.skippedFiles.length} files (git changes)`);
543
+ ```
544
+
545
+ **Parameters:**
546
+ - `tsConfigPath` - Path to tsconfig.json
547
+ - `onProgress` - Optional callback for status messages
548
+ - `isTestFile` - Optional custom test file detection function
549
+
550
+ **Returns:** `FixResults` object with counts and lists of changes
551
+
552
+ #### `formatResults(results, tsConfigDir)`
553
+
554
+ Formats analysis results into a human-readable string (same format as CLI output).
555
+
556
+ ```typescript
557
+ import { analyzeProject, formatResults } from "ts-unused";
558
+
559
+ const results = analyzeProject("./tsconfig.json");
560
+ const formatted = formatResults(results, "./");
561
+ console.log(formatted);
562
+ ```
563
+
564
+ #### `loadConfig(tsConfigPath)` / `loadConfigSync(tsConfigPath)`
565
+
566
+ Loads configuration from `unused.config.ts` in the project directory.
567
+
568
+ ```typescript
569
+ import { loadConfig, loadConfigSync, type UnusedConfig } from "ts-unused";
570
+
571
+ // Async version
572
+ const config: UnusedConfig = await loadConfig("./tsconfig.json");
573
+
574
+ // Sync version
575
+ const configSync: UnusedConfig = loadConfigSync("./tsconfig.json");
576
+
577
+ // Use with analyzeProject
578
+ const results = an![alt text](image.png)alyzeProject("./tsconfig.json", undefined, undefined, { config });
579
+ ```
580
+
581
+ #### `defineConfig(config)`
582
+
583
+ Type-safe helper for creating configuration files.
584
+
585
+ ```typescript
586
+ // unused.config.ts
587
+ import { defineConfig } from "ts-unused";
588
+
589
+ export default defineConfig({
590
+ ignoreFilePatterns: ["**/generated/**"],
591
+ ignoreExports: ["internal*"],
592
+ });
593
+ ```
594
+
595
+ #### `createIsTestFile(patterns)`
596
+
597
+ Creates a custom test file detection function from glob patterns.
598
+
599
+ ```typescript
600
+ import { analyzeProject, createIsTestFile } from "ts-unused";
601
+
602
+ const isTestFile = createIsTestFile([
603
+ "**/*.test.ts",
604
+ "**/*.spec.ts",
605
+ "**/test/**",
606
+ ]);
607
+
608
+ const results = analyzeProject("./tsconfig.json", undefined, undefined, { isTestFile });
609
+ ```
610
+
611
+ #### Pattern Matching Utilities
612
+
613
+ ```typescript
614
+ import { matchesPattern, matchesFilePattern, patternToRegex } from "ts-unused";
615
+
616
+ // Match a name against patterns
617
+ matchesPattern("internalHelper", ["internal*"]); // true
618
+ matchesPattern("publicApi", ["internal*"]); // false
619
+
620
+ // Match a file path against patterns
621
+ matchesFilePattern("/project/src/generated/types.ts", ["**/generated/**"]); // true
622
+
623
+ // Convert a glob pattern to RegExp
624
+ const regex = patternToRegex("**/*.test.ts");
625
+ regex.test("src/utils.test.ts"); // true
626
+ ```
627
+
358
628
  ## License
359
629
 
360
- MIT License
630
+ MIT License
@@ -0,0 +1,235 @@
1
+ import path from "node:path";
2
+ import { SyntaxKind } from "ts-morph";
3
+ /**
4
+ * Analyzes a function to detect union type branches in the return type that are never actually returned
5
+ */
6
+ export function analyzeFunctionReturnTypes(func, sourceFile, tsConfigDir) {
7
+ const results = [];
8
+ const functionName = func.getName();
9
+ // Skip functions without names
10
+ if (!functionName) {
11
+ return results;
12
+ }
13
+ // Get the return type node
14
+ const returnTypeNode = func.getReturnTypeNode();
15
+ if (!returnTypeNode) {
16
+ return results;
17
+ }
18
+ // Get the actual Type from the type checker
19
+ const returnType = returnTypeNode.getType();
20
+ // Try to unwrap Promise first
21
+ const unwrappedPromise = unwrapPromiseType(returnType);
22
+ const typeToCheck = unwrappedPromise || returnType;
23
+ // Check if it's a union type (or contains a union after unwrapping Promise)
24
+ if (!typeToCheck.isUnion()) {
25
+ return results;
26
+ }
27
+ // Get all union type branches
28
+ const unionTypes = typeToCheck.getUnionTypes();
29
+ if (unionTypes.length < 2) {
30
+ // Not a meaningful union
31
+ return results;
32
+ }
33
+ // Get all return statements in the function
34
+ const returnStatements = func.getDescendantsOfKind(SyntaxKind.ReturnStatement);
35
+ // Collect types of all returned values
36
+ const returnedTypes = [];
37
+ for (const returnStmt of returnStatements) {
38
+ const expression = returnStmt.getExpression();
39
+ if (expression) {
40
+ const exprType = expression.getType();
41
+ // Unwrap Promise if needed
42
+ const unwrapped = unwrapPromiseType(exprType);
43
+ returnedTypes.push(unwrapped || exprType);
44
+ }
45
+ }
46
+ // If no return statements, can't determine what's returned
47
+ if (returnedTypes.length === 0) {
48
+ return results;
49
+ }
50
+ // Check if any returned type is a generic type assignable to the whole union but not to specific branches
51
+ // This happens with destructured objects or other inferred types that satisfy the union structurally
52
+ for (const returnedType of returnedTypes) {
53
+ if (returnedType.isAssignableTo(typeToCheck)) {
54
+ // Check if this type is assignable to ANY specific union branch
55
+ let assignableToAnyBranch = false;
56
+ for (const unionBranch of unionTypes) {
57
+ if (returnedType.isAssignableTo(unionBranch)) {
58
+ assignableToAnyBranch = true;
59
+ break;
60
+ }
61
+ }
62
+ // If assignable to the union but NOT to any specific branch,
63
+ // this is a generic type that could be any branch - skip analysis
64
+ if (!assignableToAnyBranch) {
65
+ return results;
66
+ }
67
+ }
68
+ }
69
+ // Group enum literals by their parent enum
70
+ // If any enum member is returned, we consider the entire enum as "returned"
71
+ const enumGroups = new Map();
72
+ const enumsWithReturnedValues = new Set();
73
+ for (const unionBranch of unionTypes) {
74
+ if (unionBranch.isEnumLiteral()) {
75
+ const enumName = getEnumNameFromLiteral(unionBranch);
76
+ if (enumName) {
77
+ if (!enumGroups.has(enumName)) {
78
+ enumGroups.set(enumName, []);
79
+ }
80
+ enumGroups.get(enumName)?.push(unionBranch);
81
+ }
82
+ }
83
+ }
84
+ // Check which enums have at least one returned value
85
+ for (const [enumName, enumBranches] of enumGroups.entries()) {
86
+ for (const returnedType of returnedTypes) {
87
+ // Check if this returned type is any member of this enum
88
+ for (const enumBranch of enumBranches) {
89
+ if (isTypeAssignableTo(returnedType, enumBranch)) {
90
+ enumsWithReturnedValues.add(enumName);
91
+ break;
92
+ }
93
+ }
94
+ if (enumsWithReturnedValues.has(enumName)) {
95
+ break;
96
+ }
97
+ }
98
+ }
99
+ // Group union branches by display name to handle boolean (true/false) and avoid duplicates
100
+ const branchMap = new Map();
101
+ for (const unionBranch of unionTypes) {
102
+ const displayName = getTypeDisplayName(unionBranch);
103
+ if (!branchMap.has(displayName)) {
104
+ branchMap.set(displayName, unionBranch);
105
+ }
106
+ }
107
+ // Check each unique union branch to see if it's ever returned
108
+ for (const [displayName, unionBranch] of branchMap.entries()) {
109
+ // Skip enum literal branches if the enum has at least one returned value
110
+ if (unionBranch.isEnumLiteral()) {
111
+ const enumName = getEnumNameFromLiteral(unionBranch);
112
+ if (enumName && enumsWithReturnedValues.has(enumName)) {
113
+ // This enum has at least one returned value, so don't flag any of its members
114
+ continue;
115
+ }
116
+ }
117
+ let isReturned = false;
118
+ for (const returnedType of returnedTypes) {
119
+ // Check if the returned type is assignable to this union branch
120
+ if (isTypeAssignableTo(returnedType, unionBranch)) {
121
+ isReturned = true;
122
+ break;
123
+ }
124
+ }
125
+ if (!isReturned) {
126
+ // This union branch is never returned
127
+ const relativePath = path.relative(tsConfigDir, sourceFile.getFilePath());
128
+ const nameNode = func.getNameNode();
129
+ if (nameNode) {
130
+ const startPos = nameNode.getStart();
131
+ const lineStartPos = nameNode.getStartLinePos();
132
+ const character = startPos - lineStartPos + 1;
133
+ const endCharacter = character + functionName.length;
134
+ results.push({
135
+ filePath: relativePath,
136
+ functionName,
137
+ neverReturnedType: displayName,
138
+ line: nameNode.getStartLineNumber(),
139
+ character,
140
+ endCharacter,
141
+ severity: "error",
142
+ });
143
+ }
144
+ }
145
+ }
146
+ return results;
147
+ }
148
+ /**
149
+ * Unwrap Promise<T> to get T
150
+ */
151
+ function unwrapPromiseType(type) {
152
+ const symbol = type.getSymbol();
153
+ if (symbol?.getName() === "Promise") {
154
+ const typeArgs = type.getTypeArguments();
155
+ if (typeArgs.length > 0 && typeArgs[0]) {
156
+ return typeArgs[0];
157
+ }
158
+ }
159
+ return null;
160
+ }
161
+ /**
162
+ * Check if sourceType is assignable to targetType
163
+ */
164
+ function isTypeAssignableTo(sourceType, targetType) {
165
+ // Use TypeScript's built-in type checker for assignability
166
+ // This properly handles structural compatibility, inferred types, etc.
167
+ if (sourceType.isAssignableTo(targetType)) {
168
+ return true;
169
+ }
170
+ // Special handling for boolean literals (true/false)
171
+ // TypeScript represents boolean as true | false, so we need to check
172
+ // if both are boolean literals when comparing
173
+ const sourceText = sourceType.getText();
174
+ const targetText = targetType.getText();
175
+ const isBooleanLiteral = (text) => text === "true" || text === "false";
176
+ if (isBooleanLiteral(sourceText) && isBooleanLiteral(targetText)) {
177
+ return true;
178
+ }
179
+ return false;
180
+ }
181
+ /**
182
+ * Extract the enum name from an enum literal type
183
+ * For example: "Status.Idle" -> "Status"
184
+ */
185
+ function getEnumNameFromLiteral(type) {
186
+ if (!type.isEnumLiteral()) {
187
+ return null;
188
+ }
189
+ const typeText = type.getText();
190
+ // Strip import paths first
191
+ const cleanedText = typeText.replace(/import\([^)]+\)\./g, "");
192
+ // Extract enum name from "EnumName.MemberName"
193
+ const lastDotIndex = cleanedText.lastIndexOf(".");
194
+ if (lastDotIndex > 0) {
195
+ return cleanedText.substring(0, lastDotIndex);
196
+ }
197
+ return null;
198
+ }
199
+ /**
200
+ * Get a readable display name for a type
201
+ */
202
+ function getTypeDisplayName(type) {
203
+ const symbol = type.getSymbol();
204
+ // If it has a symbol with a name, use that
205
+ if (symbol) {
206
+ const name = symbol.getName();
207
+ if (name && name !== "__type") {
208
+ return name;
209
+ }
210
+ }
211
+ // Otherwise, use the type text
212
+ let typeText = type.getText();
213
+ // Strip import paths from type text
214
+ // Pattern: import("path/to/file").TypeName -> TypeName
215
+ typeText = typeText.replace(/import\([^)]+\)\./g, "");
216
+ // Simplify common type texts
217
+ if (typeText === "string")
218
+ return "string";
219
+ if (typeText === "number")
220
+ return "number";
221
+ if (typeText === "boolean")
222
+ return "boolean";
223
+ if (typeText === "true" || typeText === "false")
224
+ return "boolean";
225
+ if (typeText === "null")
226
+ return "null";
227
+ if (typeText === "undefined")
228
+ return "undefined";
229
+ // Truncate very long types (e.g., inline object types)
230
+ const MAX_TYPE_LENGTH = 100;
231
+ if (typeText.length > MAX_TYPE_LENGTH) {
232
+ return `${typeText.substring(0, MAX_TYPE_LENGTH)}...`;
233
+ }
234
+ return typeText;
235
+ }
@@ -1,3 +1,7 @@
1
1
  import type { Project, SourceFile } from "ts-morph";
2
2
  import type { IsTestFileFn, UnusedPropertyResult } from "./types";
3
- export declare function analyzeInterfaces(sourceFile: SourceFile, tsConfigDir: string, isTestFile: IsTestFileFn, results: UnusedPropertyResult[], project: Project): void;
3
+ export interface AnalyzeInterfacesOptions {
4
+ ignoreProperties?: string[];
5
+ ignoreTypes?: string[];
6
+ }
7
+ export declare function analyzeInterfaces(sourceFile: SourceFile, tsConfigDir: string, isTestFile: IsTestFileFn, results: UnusedPropertyResult[], project: Project, options?: AnalyzeInterfacesOptions): void;
@@ -0,0 +1,51 @@
1
+ import path from "node:path";
2
+ import { extractTodoComment } from "./extractTodoComment";
3
+ import { isPropertyUnused } from "./isPropertyUnused";
4
+ import { matchesPattern } from "./patternMatcher";
5
+ export function analyzeInterfaces(sourceFile, tsConfigDir, isTestFile, results, project, options = {}) {
6
+ const { ignoreProperties = [], ignoreTypes = [] } = options;
7
+ const interfaces = sourceFile.getInterfaces();
8
+ for (const iface of interfaces) {
9
+ const interfaceName = iface.getName();
10
+ // Skip ignored types
11
+ if (ignoreTypes.length > 0 && matchesPattern(interfaceName, ignoreTypes)) {
12
+ continue;
13
+ }
14
+ for (const prop of iface.getProperties()) {
15
+ const propertyName = prop.getName();
16
+ // Skip ignored properties
17
+ if (ignoreProperties.length > 0 && matchesPattern(propertyName, ignoreProperties)) {
18
+ continue;
19
+ }
20
+ const usage = isPropertyUnused(prop, isTestFile, project);
21
+ if (usage.isUnusedOrTestOnly) {
22
+ const relativePath = path.relative(tsConfigDir, sourceFile.getFilePath());
23
+ const todoComment = extractTodoComment(prop);
24
+ // Determine severity: warning for TODO, info for test-only, error for completely unused
25
+ let severity = "error";
26
+ if (todoComment) {
27
+ severity = "warning";
28
+ }
29
+ else if (usage.onlyUsedInTests) {
30
+ severity = "info";
31
+ }
32
+ const startPos = prop.getStart();
33
+ const lineStartPos = prop.getStartLinePos();
34
+ const character = startPos - lineStartPos + 1;
35
+ const endCharacter = character + propertyName.length;
36
+ const result = {
37
+ filePath: relativePath,
38
+ typeName: interfaceName,
39
+ propertyName,
40
+ line: prop.getStartLineNumber(),
41
+ character,
42
+ endCharacter,
43
+ todoComment,
44
+ severity,
45
+ onlyUsedInTests: usage.onlyUsedInTests,
46
+ };
47
+ results.push(result);
48
+ }
49
+ }
50
+ }
51
+ }
@@ -1,2 +1,13 @@
1
+ import { type UnusedConfig } from "./config";
1
2
  import type { AnalysisResults, IsTestFileFn } from "./types";
2
- export declare function analyzeProject(tsConfigPath: string, onProgress?: (current: number, total: number, filePath: string) => void, targetFilePath?: string, isTestFile?: IsTestFileFn): AnalysisResults;
3
+ export interface AnalyzeProjectOptions {
4
+ /**
5
+ * Configuration options for the analysis.
6
+ */
7
+ config?: UnusedConfig;
8
+ /**
9
+ * Custom test file detection function (overrides config.testFilePatterns).
10
+ */
11
+ isTestFile?: IsTestFileFn;
12
+ }
13
+ export declare function analyzeProject(tsConfigPath: string, onProgress?: (current: number, total: number, filePath: string) => void, targetFilePath?: string, isTestFileOrOptions?: IsTestFileFn | AnalyzeProjectOptions): AnalysisResults;