ts-unused 1.0.1 → 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.
Files changed (45) hide show
  1. package/README.md +271 -1
  2. package/dist/analyzeFunctionReturnTypes.d.ts +6 -0
  3. package/dist/analyzeFunctionReturnTypes.js +235 -0
  4. package/dist/analyzeInterfaces.d.ts +7 -0
  5. package/dist/analyzeInterfaces.js +51 -0
  6. package/dist/analyzeProject.d.ts +13 -0
  7. package/dist/analyzeProject.js +131 -0
  8. package/dist/analyzeTypeAliases.d.ts +9 -0
  9. package/dist/analyzeTypeAliases.js +70 -0
  10. package/dist/checkExportUsage.d.ts +7 -0
  11. package/dist/checkExportUsage.js +126 -0
  12. package/dist/checkGitStatus.d.ts +7 -0
  13. package/dist/checkGitStatus.js +49 -0
  14. package/dist/cli.js +267 -63
  15. package/dist/config.d.ts +96 -0
  16. package/dist/config.js +50 -0
  17. package/dist/extractTodoComment.d.ts +6 -0
  18. package/dist/extractTodoComment.js +27 -0
  19. package/dist/findNeverReturnedTypes.d.ts +6 -0
  20. package/dist/findNeverReturnedTypes.js +36 -0
  21. package/dist/findStructurallyEquivalentProperties.d.ts +12 -0
  22. package/dist/findStructurallyEquivalentProperties.js +60 -0
  23. package/dist/findUnusedExports.d.ts +7 -0
  24. package/dist/findUnusedExports.js +36 -0
  25. package/dist/findUnusedProperties.d.ts +7 -0
  26. package/dist/findUnusedProperties.js +32 -0
  27. package/dist/fixProject.d.ts +13 -0
  28. package/dist/fixProject.js +549 -0
  29. package/dist/formatResults.d.ts +2 -0
  30. package/dist/formatResults.js +112 -0
  31. package/dist/hasNoCheck.d.ts +2 -0
  32. package/dist/hasNoCheck.js +8 -0
  33. package/dist/index.d.ts +14 -0
  34. package/dist/index.js +9 -0
  35. package/dist/isPropertyUnused.d.ts +7 -0
  36. package/dist/isPropertyUnused.js +48 -0
  37. package/dist/isTestFile.d.ts +14 -0
  38. package/dist/isTestFile.js +23 -0
  39. package/dist/loadConfig.d.ts +20 -0
  40. package/dist/loadConfig.js +54 -0
  41. package/dist/patternMatcher.d.ts +26 -0
  42. package/dist/patternMatcher.js +83 -0
  43. package/dist/types.d.ts +40 -0
  44. package/dist/types.js +1 -0
  45. package/package.json +12 -3
@@ -0,0 +1,549 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { Project, SyntaxKind } from "ts-morph";
4
+ import { analyzeProject } from "./analyzeProject";
5
+ import { checkGitStatus } from "./checkGitStatus";
6
+ import { isTestFile as defaultIsTestFile } from "./isTestFile";
7
+ export function fixProject(tsConfigPath, onProgress, isTestFile = defaultIsTestFile) {
8
+ const results = {
9
+ fixedExports: 0,
10
+ fixedProperties: 0,
11
+ fixedNeverReturnedTypes: 0,
12
+ deletedFiles: 0,
13
+ skippedFiles: [],
14
+ errors: [],
15
+ };
16
+ // Analyze the project to find unused items
17
+ const analysis = analyzeProject(tsConfigPath, undefined, undefined, isTestFile);
18
+ // Get the directory containing tsconfig
19
+ const tsConfigDir = path.dirname(path.resolve(tsConfigPath));
20
+ // Check git status to avoid modifying files with local changes
21
+ const filesWithChanges = checkGitStatus(tsConfigDir);
22
+ // Create ts-morph project for making fixes
23
+ const project = new Project({
24
+ tsConfigFilePath: tsConfigPath,
25
+ });
26
+ // Fix unused files first (delete them)
27
+ const deletedFiles = new Set();
28
+ for (const relativeFilePath of analysis.unusedFiles) {
29
+ const absoluteFilePath = path.resolve(tsConfigDir, relativeFilePath);
30
+ if (filesWithChanges.has(absoluteFilePath)) {
31
+ onProgress?.(`Skipped: ${relativeFilePath} (has local git changes)`);
32
+ results.skippedFiles.push(relativeFilePath);
33
+ continue;
34
+ }
35
+ try {
36
+ onProgress?.(`Deleting: ${relativeFilePath} (all exports unused)`);
37
+ fs.unlinkSync(absoluteFilePath);
38
+ deletedFiles.add(relativeFilePath);
39
+ results.deletedFiles++;
40
+ }
41
+ catch (error) {
42
+ const errorMessage = error instanceof Error ? error.message : String(error);
43
+ results.errors.push({ file: relativeFilePath, error: errorMessage });
44
+ onProgress?.(`Error deleting ${relativeFilePath}: ${errorMessage}`);
45
+ }
46
+ }
47
+ // Clean up files that only contain broken imports after deletions
48
+ if (deletedFiles.size > 0) {
49
+ cleanupBrokenImports(project, tsConfigDir, deletedFiles, filesWithChanges, onProgress, results);
50
+ }
51
+ // Group never-returned types by file (process BEFORE exports to avoid removing functions)
52
+ const neverReturnedByFile = new Map();
53
+ for (const neverReturned of analysis.neverReturnedTypes || []) {
54
+ // Only fix ERROR severity items
55
+ if (neverReturned.severity !== "error") {
56
+ continue;
57
+ }
58
+ if (!neverReturnedByFile.has(neverReturned.filePath)) {
59
+ neverReturnedByFile.set(neverReturned.filePath, []);
60
+ }
61
+ neverReturnedByFile.get(neverReturned.filePath)?.push(neverReturned);
62
+ }
63
+ // Create a set of unused export names for quick lookup (only ERROR severity)
64
+ const unusedExportNames = new Set();
65
+ for (const unusedExport of analysis.unusedExports) {
66
+ if (unusedExport.severity === "error") {
67
+ unusedExportNames.add(`${unusedExport.filePath}:${unusedExport.exportName}`);
68
+ }
69
+ }
70
+ // Fix never-returned types first (before removing exports)
71
+ for (const [relativeFilePath, neverReturnedItems] of neverReturnedByFile.entries()) {
72
+ const absoluteFilePath = path.resolve(tsConfigDir, relativeFilePath);
73
+ // Skip if file was deleted
74
+ if (analysis.unusedFiles.includes(relativeFilePath)) {
75
+ continue;
76
+ }
77
+ if (filesWithChanges.has(absoluteFilePath)) {
78
+ if (!results.skippedFiles.includes(relativeFilePath)) {
79
+ onProgress?.(`Skipped: ${relativeFilePath} (has local git changes)`);
80
+ results.skippedFiles.push(relativeFilePath);
81
+ }
82
+ continue;
83
+ }
84
+ try {
85
+ const sourceFile = project.getSourceFile(absoluteFilePath);
86
+ if (!sourceFile) {
87
+ continue;
88
+ }
89
+ onProgress?.(`Fixing: ${relativeFilePath}`);
90
+ if (neverReturnedItems) {
91
+ for (const neverReturned of neverReturnedItems) {
92
+ // Skip if the function itself is unused (will be removed)
93
+ const exportKey = `${relativeFilePath}:${neverReturned.functionName}`;
94
+ if (unusedExportNames.has(exportKey)) {
95
+ continue;
96
+ }
97
+ if (removeNeverReturnedType(sourceFile, neverReturned.functionName, neverReturned.neverReturnedType)) {
98
+ onProgress?.(` ✓ Removed never-returned type '${neverReturned.neverReturnedType}' from ${neverReturned.functionName}`);
99
+ results.fixedNeverReturnedTypes++;
100
+ }
101
+ }
102
+ }
103
+ sourceFile.saveSync();
104
+ }
105
+ catch (error) {
106
+ const errorMessage = error instanceof Error ? error.message : String(error);
107
+ results.errors.push({ file: relativeFilePath, error: errorMessage });
108
+ onProgress?.(`Error fixing ${relativeFilePath}: ${errorMessage}`);
109
+ }
110
+ }
111
+ // Group unused exports by file (only ERROR severity)
112
+ const exportsByFile = new Map();
113
+ for (const unusedExport of analysis.unusedExports) {
114
+ // Only fix ERROR severity items (skip INFO for test-only, WARNING for TODOs)
115
+ if (unusedExport.severity !== "error") {
116
+ continue;
117
+ }
118
+ if (!exportsByFile.has(unusedExport.filePath)) {
119
+ exportsByFile.set(unusedExport.filePath, []);
120
+ }
121
+ exportsByFile.get(unusedExport.filePath)?.push(unusedExport);
122
+ }
123
+ // Fix unused exports
124
+ for (const [relativeFilePath, exports] of exportsByFile.entries()) {
125
+ const absoluteFilePath = path.resolve(tsConfigDir, relativeFilePath);
126
+ // Skip if file was deleted
127
+ if (analysis.unusedFiles.includes(relativeFilePath)) {
128
+ continue;
129
+ }
130
+ if (filesWithChanges.has(absoluteFilePath)) {
131
+ onProgress?.(`Skipped: ${relativeFilePath} (has local git changes)`);
132
+ results.skippedFiles.push(relativeFilePath);
133
+ continue;
134
+ }
135
+ try {
136
+ const sourceFile = project.getSourceFile(absoluteFilePath);
137
+ if (!sourceFile) {
138
+ continue;
139
+ }
140
+ // Only log if we haven't already logged for this file (from never-returned types)
141
+ if (!neverReturnedByFile.has(relativeFilePath)) {
142
+ onProgress?.(`Fixing: ${relativeFilePath}`);
143
+ }
144
+ for (const unusedExport of exports) {
145
+ if (removeExport(sourceFile, unusedExport.exportName)) {
146
+ onProgress?.(` ✓ Removed unused export: ${unusedExport.exportName}`);
147
+ results.fixedExports++;
148
+ }
149
+ }
150
+ sourceFile.saveSync();
151
+ }
152
+ catch (error) {
153
+ const errorMessage = error instanceof Error ? error.message : String(error);
154
+ results.errors.push({ file: relativeFilePath, error: errorMessage });
155
+ onProgress?.(`Error fixing ${relativeFilePath}: ${errorMessage}`);
156
+ }
157
+ }
158
+ // Group unused properties by file (only ERROR severity)
159
+ const propertiesByFile = new Map();
160
+ for (const unusedProperty of analysis.unusedProperties) {
161
+ // Only fix ERROR severity items (skip INFO for test-only, WARNING for TODOs)
162
+ if (unusedProperty.severity !== "error") {
163
+ continue;
164
+ }
165
+ if (!propertiesByFile.has(unusedProperty.filePath)) {
166
+ propertiesByFile.set(unusedProperty.filePath, []);
167
+ }
168
+ propertiesByFile.get(unusedProperty.filePath)?.push(unusedProperty);
169
+ }
170
+ // Fix unused properties
171
+ for (const [relativeFilePath, properties] of propertiesByFile.entries()) {
172
+ const absoluteFilePath = path.resolve(tsConfigDir, relativeFilePath);
173
+ // Skip if file was deleted
174
+ if (analysis.unusedFiles.includes(relativeFilePath)) {
175
+ continue;
176
+ }
177
+ if (filesWithChanges.has(absoluteFilePath)) {
178
+ // Only add to skipped list if not already added
179
+ if (!results.skippedFiles.includes(relativeFilePath)) {
180
+ onProgress?.(`Skipped: ${relativeFilePath} (has local git changes)`);
181
+ results.skippedFiles.push(relativeFilePath);
182
+ }
183
+ continue;
184
+ }
185
+ try {
186
+ const sourceFile = project.getSourceFile(absoluteFilePath);
187
+ if (!sourceFile) {
188
+ continue;
189
+ }
190
+ // Only log if we haven't already logged for this file (from exports or never-returned)
191
+ if (!exportsByFile.has(relativeFilePath) && !neverReturnedByFile.has(relativeFilePath)) {
192
+ onProgress?.(`Fixing: ${relativeFilePath}`);
193
+ }
194
+ for (const unusedProperty of properties) {
195
+ if (removeProperty(sourceFile, unusedProperty.typeName, unusedProperty.propertyName)) {
196
+ onProgress?.(` ✓ Removed unused property: ${unusedProperty.typeName}.${unusedProperty.propertyName}`);
197
+ results.fixedProperties++;
198
+ }
199
+ }
200
+ sourceFile.saveSync();
201
+ }
202
+ catch (error) {
203
+ const errorMessage = error instanceof Error ? error.message : String(error);
204
+ results.errors.push({ file: relativeFilePath, error: errorMessage });
205
+ onProgress?.(`Error fixing ${relativeFilePath}: ${errorMessage}`);
206
+ }
207
+ }
208
+ return results;
209
+ }
210
+ /**
211
+ * Remove an export from a source file
212
+ */
213
+ function removeExport(sourceFile, exportName) {
214
+ // Find all exported declarations with this name
215
+ const exportedDeclarations = sourceFile.getExportedDeclarations();
216
+ const declarations = exportedDeclarations.get(exportName);
217
+ if (!declarations || declarations.length === 0) {
218
+ return false;
219
+ }
220
+ for (const declaration of declarations) {
221
+ // For variable declarations, we need to remove the VariableStatement
222
+ if (declaration.getKind() === SyntaxKind.VariableDeclaration) {
223
+ const variableStatement = declaration.getFirstAncestorByKind(SyntaxKind.VariableStatement);
224
+ if (variableStatement) {
225
+ variableStatement.remove();
226
+ continue;
227
+ }
228
+ }
229
+ // Remove the node if it has a remove method
230
+ if ("remove" in declaration && typeof declaration.remove === "function") {
231
+ declaration.remove();
232
+ }
233
+ }
234
+ return true;
235
+ }
236
+ /**
237
+ * Remove a property from an interface or type alias
238
+ */
239
+ function removeProperty(sourceFile, typeName, propertyName) {
240
+ // Find interface declarations
241
+ const interfaces = sourceFile.getInterfaces();
242
+ for (const iface of interfaces) {
243
+ if (iface.getName() === typeName) {
244
+ const property = iface.getProperty(propertyName);
245
+ if (property) {
246
+ property.remove();
247
+ return true;
248
+ }
249
+ }
250
+ }
251
+ // Find type alias declarations
252
+ const typeAliases = sourceFile.getTypeAliases();
253
+ for (const typeAlias of typeAliases) {
254
+ if (typeAlias.getName() === typeName) {
255
+ const typeNode = typeAlias.getTypeNode();
256
+ if (typeNode && typeNode.getKind() === SyntaxKind.TypeLiteral) {
257
+ const typeLiteral = typeNode.asKindOrThrow(SyntaxKind.TypeLiteral);
258
+ const members = typeLiteral.getMembers();
259
+ for (const member of members) {
260
+ if (member.getKind() === SyntaxKind.PropertySignature) {
261
+ const propSig = member.asKind(SyntaxKind.PropertySignature);
262
+ if (propSig?.getName() === propertyName) {
263
+ propSig.remove();
264
+ return true;
265
+ }
266
+ }
267
+ }
268
+ }
269
+ }
270
+ }
271
+ return false;
272
+ }
273
+ /**
274
+ * Remove a never-returned type from a function's return type union
275
+ */
276
+ function removeNeverReturnedType(sourceFile, functionName, neverReturnedType) {
277
+ // Find the function
278
+ const functions = sourceFile.getFunctions();
279
+ for (const func of functions) {
280
+ if (func.getName() === functionName) {
281
+ const returnTypeNode = func.getReturnTypeNode();
282
+ if (!returnTypeNode) {
283
+ return false;
284
+ }
285
+ // Get the actual type (resolved) to check if it's a union
286
+ const returnType = returnTypeNode.getType();
287
+ // For Promise types, unwrap to get inner type
288
+ let typeToCheck = returnType;
289
+ let isPromise = false;
290
+ const symbol = returnType.getSymbol();
291
+ if (symbol?.getName() === "Promise") {
292
+ const typeArgs = returnType.getTypeArguments();
293
+ if (typeArgs.length > 0 && typeArgs[0]) {
294
+ typeToCheck = typeArgs[0];
295
+ isPromise = true;
296
+ }
297
+ }
298
+ // Check if it's a union type
299
+ if (!typeToCheck.isUnion()) {
300
+ return false;
301
+ }
302
+ const unionTypes = typeToCheck.getUnionTypes();
303
+ // Check if any of the union branches is an inline object type or enum literal
304
+ const hasInlineObjectType = unionTypes.some((ut) => ut.getSymbol()?.getName() === "__type");
305
+ const hasEnumLiteral = unionTypes.some((ut) => ut.isEnumLiteral());
306
+ if (hasInlineObjectType || hasEnumLiteral) {
307
+ // Use source text manipulation for inline object types and enum literals
308
+ const originalTypeText = returnTypeNode.getText();
309
+ // For Promise types, we need to extract the inner type text
310
+ let typeTextToModify = originalTypeText;
311
+ let promiseWrapper = "";
312
+ if (isPromise) {
313
+ const promiseMatch = originalTypeText.match(/^Promise<(.+)>$/s);
314
+ if (promiseMatch?.[1]) {
315
+ typeTextToModify = promiseMatch[1];
316
+ promiseWrapper = "Promise<>";
317
+ }
318
+ }
319
+ // Split the union by | but handle nested structures
320
+ const branches = splitUnionType(typeTextToModify);
321
+ // Normalize the type to remove for comparison
322
+ const normalizedRemove = normalizeTypeText(neverReturnedType === "true" || neverReturnedType === "false" ? "boolean" : neverReturnedType);
323
+ // Filter out the branch to remove
324
+ const remainingBranches = branches.filter((branch) => {
325
+ const trimmed = branch.trim();
326
+ // Normalize boolean for comparison
327
+ const normalized = normalizeTypeText(trimmed === "true" || trimmed === "false" ? "boolean" : trimmed);
328
+ return normalized !== normalizedRemove;
329
+ });
330
+ if (remainingBranches.length === 0 || remainingBranches.length === branches.length) {
331
+ // Either all removed (shouldn't happen) or nothing matched
332
+ return false;
333
+ }
334
+ // Build the new return type
335
+ let newReturnType = remainingBranches.join(" | ");
336
+ // Wrap in Promise if needed
337
+ if (promiseWrapper) {
338
+ newReturnType = `Promise<${newReturnType}>`;
339
+ }
340
+ // Replace the return type
341
+ func.setReturnType(newReturnType);
342
+ return true;
343
+ }
344
+ else {
345
+ // Use the old approach for named types (interfaces, type aliases, enums)
346
+ const typesToKeep = [];
347
+ for (const ut of unionTypes) {
348
+ const symbol = ut.getSymbol();
349
+ const typeName = symbol?.getName() || ut.getText();
350
+ // Normalize boolean
351
+ const normalizedName = typeName === "true" || typeName === "false" ? "boolean" : typeName;
352
+ const normalizedRemove = neverReturnedType === "true" || neverReturnedType === "false" ? "boolean" : neverReturnedType;
353
+ if (normalizedName !== normalizedRemove && !typesToKeep.includes(typeName)) {
354
+ typesToKeep.push(typeName);
355
+ }
356
+ }
357
+ // Build the new return type
358
+ let newReturnType;
359
+ if (typesToKeep.length === 1 && typesToKeep[0]) {
360
+ newReturnType = typesToKeep[0];
361
+ }
362
+ else if (typesToKeep.length > 1) {
363
+ newReturnType = typesToKeep.join(" | ");
364
+ }
365
+ if (!newReturnType) {
366
+ // All types removed, shouldn't happen
367
+ return false;
368
+ }
369
+ // Wrap in Promise if needed
370
+ if (isPromise) {
371
+ newReturnType = `Promise<${newReturnType}>`;
372
+ }
373
+ // Replace the return type
374
+ func.setReturnType(newReturnType);
375
+ return true;
376
+ }
377
+ }
378
+ }
379
+ return false;
380
+ }
381
+ /**
382
+ * Split a union type string by | while respecting nested structures
383
+ */
384
+ function splitUnionType(typeText) {
385
+ const branches = [];
386
+ let current = "";
387
+ let depth = 0;
388
+ let inString = false;
389
+ let stringChar = "";
390
+ for (let i = 0; i < typeText.length; i++) {
391
+ const char = typeText[i];
392
+ const prevChar = i > 0 ? typeText[i - 1] : "";
393
+ // Handle string literals
394
+ if ((char === '"' || char === "'") && prevChar !== "\\") {
395
+ if (!inString) {
396
+ inString = true;
397
+ stringChar = char;
398
+ }
399
+ else if (char === stringChar) {
400
+ inString = false;
401
+ stringChar = "";
402
+ }
403
+ }
404
+ if (!inString) {
405
+ // Track depth for nested structures
406
+ if (char === "{" || char === "<" || char === "(") {
407
+ depth++;
408
+ }
409
+ else if (char === "}" || char === ">" || char === ")") {
410
+ depth--;
411
+ }
412
+ else if (char === "|" && depth === 0) {
413
+ // Found a top-level union separator
414
+ branches.push(current);
415
+ current = "";
416
+ continue;
417
+ }
418
+ }
419
+ current += char;
420
+ }
421
+ // Add the last branch
422
+ if (current) {
423
+ branches.push(current);
424
+ }
425
+ return branches;
426
+ }
427
+ /**
428
+ * Normalize type text for comparison by removing optional trailing semicolons
429
+ */
430
+ function normalizeTypeText(typeText) {
431
+ // Remove semicolons before closing braces/brackets
432
+ return typeText
433
+ .replace(/;\s*([}\]>])/g, " $1")
434
+ .replace(/\s+/g, " ")
435
+ .trim();
436
+ }
437
+ /**
438
+ * Clean up files that have broken imports after file deletions
439
+ * If a file only contains re-exports from deleted files, remove those exports or delete the file
440
+ */
441
+ function cleanupBrokenImports(project, tsConfigDir, deletedFiles, filesWithChanges, onProgress, results) {
442
+ // Refresh the project to detect errors after deletions
443
+ const sourceFiles = project.getSourceFiles();
444
+ for (const sourceFile of sourceFiles) {
445
+ const relativePath = path.relative(tsConfigDir, sourceFile.getFilePath());
446
+ const absolutePath = sourceFile.getFilePath();
447
+ // Skip if file has git changes
448
+ if (filesWithChanges.has(absolutePath)) {
449
+ continue;
450
+ }
451
+ // Check for broken imports/exports
452
+ const exportDeclarations = sourceFile.getExportDeclarations();
453
+ let hasValidExports = false;
454
+ const brokenExports = [];
455
+ for (const exportDecl of exportDeclarations) {
456
+ const moduleSpecifier = exportDecl.getModuleSpecifierValue();
457
+ if (moduleSpecifier) {
458
+ // Resolve the import path
459
+ const resolvedPath = resolveImportPath(sourceFile.getFilePath(), moduleSpecifier);
460
+ const relativeResolvedPath = resolvedPath ? path.relative(tsConfigDir, resolvedPath) : null;
461
+ // Check if it references a deleted file
462
+ if (relativeResolvedPath && deletedFiles.has(relativeResolvedPath)) {
463
+ brokenExports.push(exportDecl);
464
+ }
465
+ else {
466
+ hasValidExports = true;
467
+ }
468
+ }
469
+ else {
470
+ // Export without module specifier (e.g., export { foo })
471
+ hasValidExports = true;
472
+ }
473
+ }
474
+ // Also check if file has any non-export declarations
475
+ const hasOwnDeclarations = sourceFile.getFunctions().length > 0 ||
476
+ sourceFile.getClasses().length > 0 ||
477
+ sourceFile.getInterfaces().length > 0 ||
478
+ sourceFile.getTypeAliases().length > 0 ||
479
+ sourceFile.getVariableDeclarations().length > 0 ||
480
+ sourceFile.getEnums().length > 0;
481
+ if (hasOwnDeclarations) {
482
+ hasValidExports = true;
483
+ }
484
+ // If file only has broken re-exports, delete it
485
+ if (brokenExports.length > 0 && !hasValidExports) {
486
+ try {
487
+ onProgress?.(`Deleting: ${relativePath} (only re-exports from deleted files)`);
488
+ fs.unlinkSync(absolutePath);
489
+ if (results) {
490
+ results.deletedFiles++;
491
+ }
492
+ }
493
+ catch (error) {
494
+ const errorMessage = error instanceof Error ? error.message : String(error);
495
+ if (results) {
496
+ results.errors.push({ file: relativePath, error: errorMessage });
497
+ }
498
+ onProgress?.(`Error deleting ${relativePath}: ${errorMessage}`);
499
+ }
500
+ }
501
+ // Otherwise, just remove the broken export declarations
502
+ else if (brokenExports.length > 0) {
503
+ try {
504
+ onProgress?.(`Fixing: ${relativePath}`);
505
+ for (const exportDecl of brokenExports) {
506
+ const moduleSpec = exportDecl.getModuleSpecifierValue();
507
+ exportDecl.remove();
508
+ onProgress?.(` ✓ Removed broken re-export from: ${moduleSpec}`);
509
+ }
510
+ sourceFile.saveSync();
511
+ }
512
+ catch (error) {
513
+ const errorMessage = error instanceof Error ? error.message : String(error);
514
+ if (results) {
515
+ results.errors.push({ file: relativePath, error: errorMessage });
516
+ }
517
+ onProgress?.(`Error fixing ${relativePath}: ${errorMessage}`);
518
+ }
519
+ }
520
+ }
521
+ }
522
+ /**
523
+ * Resolve an import path to an absolute file path
524
+ */
525
+ function resolveImportPath(fromFile, importPath) {
526
+ const fromDir = path.dirname(fromFile);
527
+ // Handle relative imports
528
+ if (importPath.startsWith(".")) {
529
+ const resolved = path.resolve(fromDir, importPath);
530
+ // Try with different extensions
531
+ const extensions = [".ts", ".tsx", ".js", ".jsx"];
532
+ for (const ext of extensions) {
533
+ const withExt = resolved + ext;
534
+ if (fs.existsSync(withExt)) {
535
+ return withExt;
536
+ }
537
+ }
538
+ // Try as index file
539
+ for (const ext of extensions) {
540
+ const indexFile = path.join(resolved, `index${ext}`);
541
+ if (fs.existsSync(indexFile)) {
542
+ return indexFile;
543
+ }
544
+ }
545
+ // Return as-is if no extension found (might be a directory or invalid)
546
+ return `${resolved}.ts`;
547
+ }
548
+ return null;
549
+ }
@@ -0,0 +1,2 @@
1
+ import type { AnalysisResults } from "./types";
2
+ export declare function formatResults(results: AnalysisResults, tsConfigDir: string): string;
@@ -0,0 +1,112 @@
1
+ import path from "node:path";
2
+ function getSeverityMarker(severity) {
3
+ const markers = {
4
+ error: "[ERROR]",
5
+ warning: "[WARNING]",
6
+ info: "[INFO]",
7
+ };
8
+ return markers[severity];
9
+ }
10
+ function formatExportLine(item) {
11
+ const marker = getSeverityMarker(item.severity);
12
+ if (item.onlyUsedInTests) {
13
+ return ` ${item.exportName}:${item.line}:${item.character}-${item.endCharacter} ${marker} (Used only in tests)`;
14
+ }
15
+ return ` ${item.exportName}:${item.line}:${item.character}-${item.endCharacter} ${marker} (Unused ${item.kind})`;
16
+ }
17
+ function formatPropertyLine(item) {
18
+ const status = item.onlyUsedInTests ? "Used only in tests" : "Unused property";
19
+ const todoSuffix = item.todoComment ? `: [TODO] ${item.todoComment}` : "";
20
+ const marker = getSeverityMarker(item.severity);
21
+ return ` ${item.typeName}.${item.propertyName}:${item.line}:${item.character}-${item.endCharacter} ${marker} (${status}${todoSuffix})`;
22
+ }
23
+ function formatNeverReturnedLine(item) {
24
+ const marker = getSeverityMarker(item.severity);
25
+ return ` ${item.functionName}:${item.line}:${item.character}-${item.endCharacter} ${marker} (Type '${item.neverReturnedType}' in return type is never returned)`;
26
+ }
27
+ function formatGroupedItems(items, formatter, tsConfigDir, cwd) {
28
+ const lines = [];
29
+ const grouped = groupByFile(items);
30
+ for (const [filePath, groupItems] of grouped.entries()) {
31
+ // filePath is relative to tsConfigDir, convert to absolute then to relative from cwd
32
+ const absolutePath = path.resolve(tsConfigDir, filePath);
33
+ const relativePath = path.relative(cwd, absolutePath);
34
+ lines.push(relativePath);
35
+ for (const item of groupItems) {
36
+ lines.push(formatter(item));
37
+ }
38
+ lines.push("");
39
+ }
40
+ return lines;
41
+ }
42
+ export function formatResults(results, tsConfigDir) {
43
+ const lines = [];
44
+ const cwd = process.cwd();
45
+ // Create a set of unused export names (interfaces/types) for quick lookup
46
+ const unusedExportNames = new Set();
47
+ for (const exportItem of results.unusedExports) {
48
+ unusedExportNames.add(exportItem.exportName);
49
+ }
50
+ // Filter out properties whose parent type/interface is already reported as unused
51
+ const propertiesToReport = results.unusedProperties.filter((prop) => !unusedExportNames.has(prop.typeName));
52
+ // Create a set of completely unused files to exclude from export reporting
53
+ const unusedFileSet = new Set(results.unusedFiles);
54
+ // Filter out exports from completely unused files
55
+ const exportsToReport = results.unusedExports.filter((exp) => !unusedFileSet.has(exp.filePath));
56
+ // Report completely unused files first
57
+ if (results.unusedFiles.length > 0) {
58
+ lines.push("Completely Unused Files:");
59
+ lines.push("");
60
+ for (const filePath of results.unusedFiles) {
61
+ // filePath is relative to tsConfigDir, convert to absolute then to relative from cwd
62
+ const absolutePath = path.resolve(tsConfigDir, filePath);
63
+ const relativePath = path.relative(cwd, absolutePath);
64
+ lines.push(relativePath);
65
+ lines.push(" file:1:1-1 [ERROR] (All exports unused - file can be deleted)");
66
+ lines.push("");
67
+ }
68
+ }
69
+ if (exportsToReport.length > 0) {
70
+ lines.push("Unused Exports:");
71
+ lines.push("");
72
+ lines.push(...formatGroupedItems(exportsToReport, formatExportLine, tsConfigDir, cwd));
73
+ }
74
+ if (propertiesToReport.length > 0) {
75
+ lines.push("Unused Type/Interface Properties:");
76
+ lines.push("");
77
+ lines.push(...formatGroupedItems(propertiesToReport, formatPropertyLine, tsConfigDir, cwd));
78
+ }
79
+ const neverReturnedTypes = results.neverReturnedTypes || [];
80
+ if (neverReturnedTypes.length > 0) {
81
+ lines.push("Never-Returned Types:");
82
+ lines.push("");
83
+ lines.push(...formatGroupedItems(neverReturnedTypes, formatNeverReturnedLine, tsConfigDir, cwd));
84
+ }
85
+ if (results.unusedFiles.length === 0 &&
86
+ exportsToReport.length === 0 &&
87
+ propertiesToReport.length === 0 &&
88
+ neverReturnedTypes.length === 0) {
89
+ lines.push("No unused exports or properties found!");
90
+ }
91
+ else {
92
+ lines.push("Summary:");
93
+ lines.push(` Completely unused files: ${results.unusedFiles.length}`);
94
+ lines.push(` Unused exports: ${exportsToReport.length}`);
95
+ lines.push(` Unused properties: ${propertiesToReport.length}`);
96
+ lines.push(` Never-returned types: ${neverReturnedTypes.length}`);
97
+ }
98
+ return lines.join("\n");
99
+ }
100
+ function groupByFile(items) {
101
+ const grouped = new Map();
102
+ for (const item of items) {
103
+ const existing = grouped.get(item.filePath);
104
+ if (existing) {
105
+ existing.push(item);
106
+ }
107
+ else {
108
+ grouped.set(item.filePath, [item]);
109
+ }
110
+ }
111
+ return grouped;
112
+ }
@@ -0,0 +1,2 @@
1
+ import type { SourceFile } from "ts-morph";
2
+ export declare function hasNoCheck(sourceFile: SourceFile): boolean;
@@ -0,0 +1,8 @@
1
+ export function hasNoCheck(sourceFile) {
2
+ const fullText = sourceFile.getFullText();
3
+ const firstLine = fullText.split("\n")[0];
4
+ if (!firstLine) {
5
+ return false;
6
+ }
7
+ return firstLine.trim() === "// @ts-nocheck";
8
+ }