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 +271 -1
- package/dist/analyzeFunctionReturnTypes.js +235 -0
- package/dist/analyzeInterfaces.d.ts +5 -1
- package/dist/analyzeInterfaces.js +51 -0
- package/dist/analyzeProject.d.ts +12 -1
- package/dist/analyzeProject.js +131 -0
- package/dist/analyzeTypeAliases.d.ts +7 -3
- package/dist/analyzeTypeAliases.js +70 -0
- package/dist/checkExportUsage.d.ts +5 -1
- package/dist/checkExportUsage.js +126 -0
- package/dist/checkGitStatus.js +49 -0
- package/dist/cli.js +267 -63
- package/dist/config.d.ts +96 -0
- package/dist/config.js +50 -0
- package/dist/extractTodoComment.js +27 -0
- package/dist/findNeverReturnedTypes.d.ts +4 -1
- package/dist/findNeverReturnedTypes.js +36 -0
- package/dist/findStructurallyEquivalentProperties.js +60 -0
- package/dist/findUnusedExports.d.ts +5 -1
- package/dist/findUnusedExports.js +36 -0
- package/dist/findUnusedProperties.d.ts +5 -1
- package/dist/findUnusedProperties.js +32 -0
- package/dist/fixProject.js +549 -0
- package/dist/formatResults.js +112 -0
- package/dist/hasNoCheck.js +8 -0
- package/dist/index.d.ts +9 -4
- package/dist/index.js +9 -1251
- package/dist/isPropertyUnused.js +48 -0
- package/dist/isTestFile.d.ts +12 -0
- package/dist/isTestFile.js +23 -0
- package/dist/loadConfig.d.ts +20 -0
- package/dist/loadConfig.js +54 -0
- package/dist/patternMatcher.d.ts +26 -0
- package/dist/patternMatcher.js +83 -0
- package/dist/types.js +1 -0
- package/package.json +4 -4
|
@@ -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,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
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
|
+
export type { AnalyzeProjectOptions } from "./analyzeProject";
|
|
1
2
|
export { analyzeProject } from "./analyzeProject";
|
|
2
|
-
export {
|
|
3
|
-
export {
|
|
3
|
+
export type { UnusedConfig } from "./config";
|
|
4
|
+
export { defaultConfig, defineConfig, mergeConfig } from "./config";
|
|
4
5
|
export { findUnusedExports } from "./findUnusedExports";
|
|
5
6
|
export { findUnusedProperties } from "./findUnusedProperties";
|
|
6
|
-
export { isTestFile } from "./isTestFile";
|
|
7
|
-
export type { AnalysisResults, ExportKind, IsTestFileFn, NeverReturnedTypeResult, Severity, UnusedExportResult, UnusedPropertyResult, } from "./types";
|
|
8
7
|
export type { FixResults } from "./fixProject";
|
|
8
|
+
export { fixProject } from "./fixProject";
|
|
9
|
+
export { formatResults } from "./formatResults";
|
|
9
10
|
export type { PropertyUsageResult } from "./isPropertyUnused";
|
|
11
|
+
export { createIsTestFile, isTestFile } from "./isTestFile";
|
|
12
|
+
export { loadConfig, loadConfigSync } from "./loadConfig";
|
|
13
|
+
export { matchesFilePattern, matchesPattern, patternToRegex } from "./patternMatcher";
|
|
14
|
+
export type { AnalysisResults, ExportKind, IsTestFileFn, NeverReturnedTypeResult, Severity, UnusedExportResult, UnusedPropertyResult, } from "./types";
|