ts-unused 1.0.0 → 1.0.2
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 +67 -2
- package/dist/analyzeFunctionReturnTypes.d.ts +6 -0
- package/dist/analyzeInterfaces.d.ts +3 -0
- package/dist/analyzeProject.d.ts +2 -0
- package/dist/analyzeTypeAliases.d.ts +5 -0
- package/dist/checkExportUsage.d.ts +3 -0
- package/dist/checkGitStatus.d.ts +7 -0
- package/dist/cli.js +477 -64
- package/dist/extractTodoComment.d.ts +6 -0
- package/dist/findNeverReturnedTypes.d.ts +3 -0
- package/dist/findStructurallyEquivalentProperties.d.ts +12 -0
- package/dist/findUnusedExports.d.ts +3 -0
- package/dist/findUnusedProperties.d.ts +3 -0
- package/dist/fixProject.d.ts +13 -0
- package/dist/formatResults.d.ts +2 -0
- package/dist/hasNoCheck.d.ts +2 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +1251 -0
- package/dist/isPropertyUnused.d.ts +7 -0
- package/dist/isTestFile.d.ts +2 -0
- package/dist/types.d.ts +40 -0
- package/package.json +15 -4
package/dist/index.js
ADDED
|
@@ -0,0 +1,1251 @@
|
|
|
1
|
+
// src/analyzeProject.ts
|
|
2
|
+
import path8 from "node:path";
|
|
3
|
+
import { Project } from "ts-morph";
|
|
4
|
+
|
|
5
|
+
// src/findNeverReturnedTypes.ts
|
|
6
|
+
import path2 from "node:path";
|
|
7
|
+
import { SyntaxKind as SyntaxKind2 } from "ts-morph";
|
|
8
|
+
|
|
9
|
+
// src/analyzeFunctionReturnTypes.ts
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { SyntaxKind } from "ts-morph";
|
|
12
|
+
function analyzeFunctionReturnTypes(func, sourceFile, tsConfigDir) {
|
|
13
|
+
const results = [];
|
|
14
|
+
const functionName = func.getName();
|
|
15
|
+
if (!functionName) {
|
|
16
|
+
return results;
|
|
17
|
+
}
|
|
18
|
+
const returnTypeNode = func.getReturnTypeNode();
|
|
19
|
+
if (!returnTypeNode) {
|
|
20
|
+
return results;
|
|
21
|
+
}
|
|
22
|
+
const returnType = returnTypeNode.getType();
|
|
23
|
+
const unwrappedPromise = unwrapPromiseType(returnType);
|
|
24
|
+
const typeToCheck = unwrappedPromise || returnType;
|
|
25
|
+
if (!typeToCheck.isUnion()) {
|
|
26
|
+
return results;
|
|
27
|
+
}
|
|
28
|
+
const unionTypes = typeToCheck.getUnionTypes();
|
|
29
|
+
if (unionTypes.length < 2) {
|
|
30
|
+
return results;
|
|
31
|
+
}
|
|
32
|
+
const returnStatements = func.getDescendantsOfKind(SyntaxKind.ReturnStatement);
|
|
33
|
+
const returnedTypes = [];
|
|
34
|
+
for (const returnStmt of returnStatements) {
|
|
35
|
+
const expression = returnStmt.getExpression();
|
|
36
|
+
if (expression) {
|
|
37
|
+
const exprType = expression.getType();
|
|
38
|
+
const unwrapped = unwrapPromiseType(exprType);
|
|
39
|
+
returnedTypes.push(unwrapped || exprType);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (returnedTypes.length === 0) {
|
|
43
|
+
return results;
|
|
44
|
+
}
|
|
45
|
+
for (const returnedType of returnedTypes) {
|
|
46
|
+
if (returnedType.isAssignableTo(typeToCheck)) {
|
|
47
|
+
let assignableToAnyBranch = false;
|
|
48
|
+
for (const unionBranch of unionTypes) {
|
|
49
|
+
if (returnedType.isAssignableTo(unionBranch)) {
|
|
50
|
+
assignableToAnyBranch = true;
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (!assignableToAnyBranch) {
|
|
55
|
+
return results;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const enumGroups = new Map;
|
|
60
|
+
const enumsWithReturnedValues = new Set;
|
|
61
|
+
for (const unionBranch of unionTypes) {
|
|
62
|
+
if (unionBranch.isEnumLiteral()) {
|
|
63
|
+
const enumName = getEnumNameFromLiteral(unionBranch);
|
|
64
|
+
if (enumName) {
|
|
65
|
+
if (!enumGroups.has(enumName)) {
|
|
66
|
+
enumGroups.set(enumName, []);
|
|
67
|
+
}
|
|
68
|
+
enumGroups.get(enumName)?.push(unionBranch);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
for (const [enumName, enumBranches] of enumGroups.entries()) {
|
|
73
|
+
for (const returnedType of returnedTypes) {
|
|
74
|
+
for (const enumBranch of enumBranches) {
|
|
75
|
+
if (isTypeAssignableTo(returnedType, enumBranch)) {
|
|
76
|
+
enumsWithReturnedValues.add(enumName);
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (enumsWithReturnedValues.has(enumName)) {
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const branchMap = new Map;
|
|
86
|
+
for (const unionBranch of unionTypes) {
|
|
87
|
+
const displayName = getTypeDisplayName(unionBranch);
|
|
88
|
+
if (!branchMap.has(displayName)) {
|
|
89
|
+
branchMap.set(displayName, unionBranch);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
for (const [displayName, unionBranch] of branchMap.entries()) {
|
|
93
|
+
if (unionBranch.isEnumLiteral()) {
|
|
94
|
+
const enumName = getEnumNameFromLiteral(unionBranch);
|
|
95
|
+
if (enumName && enumsWithReturnedValues.has(enumName)) {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
let isReturned = false;
|
|
100
|
+
for (const returnedType of returnedTypes) {
|
|
101
|
+
if (isTypeAssignableTo(returnedType, unionBranch)) {
|
|
102
|
+
isReturned = true;
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (!isReturned) {
|
|
107
|
+
const relativePath = path.relative(tsConfigDir, sourceFile.getFilePath());
|
|
108
|
+
const nameNode = func.getNameNode();
|
|
109
|
+
if (nameNode) {
|
|
110
|
+
const startPos = nameNode.getStart();
|
|
111
|
+
const lineStartPos = nameNode.getStartLinePos();
|
|
112
|
+
const character = startPos - lineStartPos + 1;
|
|
113
|
+
const endCharacter = character + functionName.length;
|
|
114
|
+
results.push({
|
|
115
|
+
filePath: relativePath,
|
|
116
|
+
functionName,
|
|
117
|
+
neverReturnedType: displayName,
|
|
118
|
+
line: nameNode.getStartLineNumber(),
|
|
119
|
+
character,
|
|
120
|
+
endCharacter,
|
|
121
|
+
severity: "error"
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return results;
|
|
127
|
+
}
|
|
128
|
+
function unwrapPromiseType(type) {
|
|
129
|
+
const symbol = type.getSymbol();
|
|
130
|
+
if (symbol?.getName() === "Promise") {
|
|
131
|
+
const typeArgs = type.getTypeArguments();
|
|
132
|
+
if (typeArgs.length > 0 && typeArgs[0]) {
|
|
133
|
+
return typeArgs[0];
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
function isTypeAssignableTo(sourceType, targetType) {
|
|
139
|
+
if (sourceType.isAssignableTo(targetType)) {
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
const sourceText = sourceType.getText();
|
|
143
|
+
const targetText = targetType.getText();
|
|
144
|
+
const isBooleanLiteral = (text) => text === "true" || text === "false";
|
|
145
|
+
if (isBooleanLiteral(sourceText) && isBooleanLiteral(targetText)) {
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
function getEnumNameFromLiteral(type) {
|
|
151
|
+
if (!type.isEnumLiteral()) {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
const typeText = type.getText();
|
|
155
|
+
const cleanedText = typeText.replace(/import\([^)]+\)\./g, "");
|
|
156
|
+
const lastDotIndex = cleanedText.lastIndexOf(".");
|
|
157
|
+
if (lastDotIndex > 0) {
|
|
158
|
+
return cleanedText.substring(0, lastDotIndex);
|
|
159
|
+
}
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
function getTypeDisplayName(type) {
|
|
163
|
+
const symbol = type.getSymbol();
|
|
164
|
+
if (symbol) {
|
|
165
|
+
const name = symbol.getName();
|
|
166
|
+
if (name && name !== "__type") {
|
|
167
|
+
return name;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
let typeText = type.getText();
|
|
171
|
+
typeText = typeText.replace(/import\([^)]+\)\./g, "");
|
|
172
|
+
if (typeText === "string")
|
|
173
|
+
return "string";
|
|
174
|
+
if (typeText === "number")
|
|
175
|
+
return "number";
|
|
176
|
+
if (typeText === "boolean")
|
|
177
|
+
return "boolean";
|
|
178
|
+
if (typeText === "true" || typeText === "false")
|
|
179
|
+
return "boolean";
|
|
180
|
+
if (typeText === "null")
|
|
181
|
+
return "null";
|
|
182
|
+
if (typeText === "undefined")
|
|
183
|
+
return "undefined";
|
|
184
|
+
const MAX_TYPE_LENGTH = 100;
|
|
185
|
+
if (typeText.length > MAX_TYPE_LENGTH) {
|
|
186
|
+
return `${typeText.substring(0, MAX_TYPE_LENGTH)}...`;
|
|
187
|
+
}
|
|
188
|
+
return typeText;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// src/hasNoCheck.ts
|
|
192
|
+
function hasNoCheck(sourceFile) {
|
|
193
|
+
const fullText = sourceFile.getFullText();
|
|
194
|
+
const firstLine = fullText.split(`
|
|
195
|
+
`)[0];
|
|
196
|
+
if (!firstLine) {
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
return firstLine.trim() === "// @ts-nocheck";
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// src/findNeverReturnedTypes.ts
|
|
203
|
+
function findNeverReturnedTypes(project, tsConfigDir, isTestFile, onProgress, targetFilePath) {
|
|
204
|
+
const results = [];
|
|
205
|
+
for (const sourceFile of project.getSourceFiles()) {
|
|
206
|
+
if (isTestFile(sourceFile)) {
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
if (hasNoCheck(sourceFile)) {
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
if (targetFilePath && sourceFile.getFilePath() !== targetFilePath) {
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (onProgress) {
|
|
216
|
+
const relativePath = path2.relative(tsConfigDir, sourceFile.getFilePath());
|
|
217
|
+
onProgress(relativePath);
|
|
218
|
+
}
|
|
219
|
+
const functions = sourceFile.getDescendantsOfKind(SyntaxKind2.FunctionDeclaration);
|
|
220
|
+
for (const func of functions) {
|
|
221
|
+
const funcResults = analyzeFunctionReturnTypes(func, sourceFile, tsConfigDir);
|
|
222
|
+
results.push(...funcResults);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return results;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// src/findUnusedExports.ts
|
|
229
|
+
import path4 from "node:path";
|
|
230
|
+
|
|
231
|
+
// src/checkExportUsage.ts
|
|
232
|
+
import path3 from "node:path";
|
|
233
|
+
import { Node } from "ts-morph";
|
|
234
|
+
function getExportKind(declaration) {
|
|
235
|
+
if (Node.isFunctionDeclaration(declaration)) {
|
|
236
|
+
return "function";
|
|
237
|
+
}
|
|
238
|
+
if (Node.isClassDeclaration(declaration)) {
|
|
239
|
+
return "class";
|
|
240
|
+
}
|
|
241
|
+
if (Node.isInterfaceDeclaration(declaration)) {
|
|
242
|
+
return "interface";
|
|
243
|
+
}
|
|
244
|
+
if (Node.isTypeAliasDeclaration(declaration)) {
|
|
245
|
+
return "type";
|
|
246
|
+
}
|
|
247
|
+
if (Node.isEnumDeclaration(declaration)) {
|
|
248
|
+
return "enum";
|
|
249
|
+
}
|
|
250
|
+
if (Node.isModuleDeclaration(declaration)) {
|
|
251
|
+
return "namespace";
|
|
252
|
+
}
|
|
253
|
+
if (Node.isVariableDeclaration(declaration)) {
|
|
254
|
+
const parent = declaration.getParent();
|
|
255
|
+
if (Node.isVariableDeclarationList(parent)) {
|
|
256
|
+
const declarationKind = parent.getDeclarationKind();
|
|
257
|
+
if (declarationKind === "const") {
|
|
258
|
+
return "const";
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return "variable";
|
|
262
|
+
}
|
|
263
|
+
return "export";
|
|
264
|
+
}
|
|
265
|
+
function getNameNode(declaration) {
|
|
266
|
+
if (Node.isFunctionDeclaration(declaration) || Node.isClassDeclaration(declaration) || Node.isInterfaceDeclaration(declaration) || Node.isTypeAliasDeclaration(declaration) || Node.isEnumDeclaration(declaration) || Node.isVariableDeclaration(declaration)) {
|
|
267
|
+
return declaration.getNameNode();
|
|
268
|
+
}
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
function checkExportUsage(exportName, declarations, sourceFile, tsConfigDir, isTestFile) {
|
|
272
|
+
const firstDeclaration = declarations[0];
|
|
273
|
+
if (!firstDeclaration) {
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
const declarationSourceFile = firstDeclaration.getSourceFile();
|
|
277
|
+
if (declarationSourceFile.getFilePath() !== sourceFile.getFilePath()) {
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
if (!Node.isReferenceFindable(firstDeclaration)) {
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
const references = firstDeclaration.findReferences();
|
|
284
|
+
let totalReferences = 0;
|
|
285
|
+
let testReferences = 0;
|
|
286
|
+
let nonTestReferences = 0;
|
|
287
|
+
for (const refGroup of references) {
|
|
288
|
+
const refs = refGroup.getReferences();
|
|
289
|
+
for (const ref of refs) {
|
|
290
|
+
const refSourceFile = ref.getSourceFile();
|
|
291
|
+
totalReferences++;
|
|
292
|
+
if (isTestFile(refSourceFile)) {
|
|
293
|
+
testReferences++;
|
|
294
|
+
} else {
|
|
295
|
+
nonTestReferences++;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
const onlyUsedInTests = nonTestReferences === 1 && testReferences > 0;
|
|
300
|
+
if (totalReferences > 1 && !onlyUsedInTests) {
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
const kind = getExportKind(firstDeclaration);
|
|
304
|
+
const relativePath = path3.relative(tsConfigDir, sourceFile.getFilePath());
|
|
305
|
+
const severity = onlyUsedInTests ? "info" : "error";
|
|
306
|
+
const nameNode = getNameNode(firstDeclaration);
|
|
307
|
+
const positionNode = nameNode || firstDeclaration;
|
|
308
|
+
const startPos = positionNode.getStart();
|
|
309
|
+
const lineStartPos = positionNode.getStartLinePos();
|
|
310
|
+
const character = startPos - lineStartPos + 1;
|
|
311
|
+
const endCharacter = character + exportName.length;
|
|
312
|
+
const result = {
|
|
313
|
+
filePath: relativePath,
|
|
314
|
+
exportName,
|
|
315
|
+
line: firstDeclaration.getStartLineNumber(),
|
|
316
|
+
character,
|
|
317
|
+
endCharacter,
|
|
318
|
+
kind,
|
|
319
|
+
severity,
|
|
320
|
+
onlyUsedInTests
|
|
321
|
+
};
|
|
322
|
+
return result;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// src/findUnusedExports.ts
|
|
326
|
+
function findUnusedExports(project, tsConfigDir, isTestFile, onProgress, targetFilePath) {
|
|
327
|
+
const results = [];
|
|
328
|
+
for (const sourceFile of project.getSourceFiles()) {
|
|
329
|
+
if (isTestFile(sourceFile)) {
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
if (hasNoCheck(sourceFile)) {
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
if (targetFilePath && sourceFile.getFilePath() !== targetFilePath) {
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
if (onProgress) {
|
|
339
|
+
const relativePath = path4.relative(tsConfigDir, sourceFile.getFilePath());
|
|
340
|
+
onProgress(relativePath);
|
|
341
|
+
}
|
|
342
|
+
const exports = sourceFile.getExportedDeclarations();
|
|
343
|
+
for (const [exportName, declarations] of exports.entries()) {
|
|
344
|
+
const unusedExport = checkExportUsage(exportName, declarations, sourceFile, tsConfigDir, isTestFile);
|
|
345
|
+
if (unusedExport) {
|
|
346
|
+
results.push(unusedExport);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return results;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// src/findUnusedProperties.ts
|
|
354
|
+
import path7 from "node:path";
|
|
355
|
+
|
|
356
|
+
// src/analyzeInterfaces.ts
|
|
357
|
+
import path5 from "node:path";
|
|
358
|
+
|
|
359
|
+
// src/extractTodoComment.ts
|
|
360
|
+
function extractTodoComment(prop) {
|
|
361
|
+
const leadingComments = prop.getLeadingCommentRanges();
|
|
362
|
+
for (const comment of leadingComments) {
|
|
363
|
+
const commentText = comment.getText();
|
|
364
|
+
const singleLineMatch = commentText.match(/\/\/\s*TODO\s+(.+)/i);
|
|
365
|
+
if (singleLineMatch) {
|
|
366
|
+
const todoText = singleLineMatch[1];
|
|
367
|
+
if (todoText) {
|
|
368
|
+
return todoText.trim();
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
const multiLineMatch = commentText.match(/\/\*+\s*TODO\s+(.+?)\*\//is);
|
|
372
|
+
if (multiLineMatch) {
|
|
373
|
+
const todoText = multiLineMatch[1];
|
|
374
|
+
if (todoText) {
|
|
375
|
+
return todoText.trim().replace(/\s+/g, " ");
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// src/findStructurallyEquivalentProperties.ts
|
|
383
|
+
import { SyntaxKind as SyntaxKind3 } from "ts-morph";
|
|
384
|
+
function checkInterfaceProperties(iface, propName, propType, originalProp, equivalentProps) {
|
|
385
|
+
const properties = iface.getProperties();
|
|
386
|
+
for (const p of properties) {
|
|
387
|
+
if (p === originalProp) {
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
if (p.getName() === propName) {
|
|
391
|
+
const pType = p.getType().getText();
|
|
392
|
+
if (pType === propType) {
|
|
393
|
+
equivalentProps.push(p);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
function checkTypeAliasProperties(typeAlias, propName, propType, originalProp, equivalentProps) {
|
|
399
|
+
const typeNode = typeAlias.getTypeNode();
|
|
400
|
+
if (!typeNode || typeNode.getKind() !== SyntaxKind3.TypeLiteral) {
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
const properties = typeNode.getChildren().filter((child) => child.getKind() === SyntaxKind3.PropertySignature);
|
|
404
|
+
for (const p of properties) {
|
|
405
|
+
if (p === originalProp) {
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
if (p.getKind() === SyntaxKind3.PropertySignature && p.getName() === propName) {
|
|
409
|
+
const pType = p.getType().getText();
|
|
410
|
+
if (pType === propType) {
|
|
411
|
+
equivalentProps.push(p);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
function findStructurallyEquivalentProperties(prop, project) {
|
|
417
|
+
const propName = prop.getName();
|
|
418
|
+
const propType = prop.getType().getText();
|
|
419
|
+
const equivalentProps = [];
|
|
420
|
+
const sourceFiles = project.getSourceFiles();
|
|
421
|
+
for (const sourceFile of sourceFiles) {
|
|
422
|
+
const interfaces = sourceFile.getInterfaces();
|
|
423
|
+
for (const iface of interfaces) {
|
|
424
|
+
checkInterfaceProperties(iface, propName, propType, prop, equivalentProps);
|
|
425
|
+
}
|
|
426
|
+
const typeAliases = sourceFile.getTypeAliases();
|
|
427
|
+
for (const typeAlias of typeAliases) {
|
|
428
|
+
checkTypeAliasProperties(typeAlias, propName, propType, prop, equivalentProps);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
return equivalentProps;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// src/isPropertyUnused.ts
|
|
435
|
+
function countPropertyReferences(prop, isTestFile) {
|
|
436
|
+
const references = prop.findReferences();
|
|
437
|
+
let totalReferences = 0;
|
|
438
|
+
let testReferences = 0;
|
|
439
|
+
let nonTestReferences = 0;
|
|
440
|
+
for (const refGroup of references) {
|
|
441
|
+
const refs = refGroup.getReferences();
|
|
442
|
+
for (const ref of refs) {
|
|
443
|
+
const refSourceFile = ref.getSourceFile();
|
|
444
|
+
totalReferences++;
|
|
445
|
+
if (isTestFile(refSourceFile)) {
|
|
446
|
+
testReferences++;
|
|
447
|
+
} else {
|
|
448
|
+
nonTestReferences++;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
const result = {
|
|
453
|
+
totalReferences,
|
|
454
|
+
testReferences,
|
|
455
|
+
nonTestReferences
|
|
456
|
+
};
|
|
457
|
+
return result;
|
|
458
|
+
}
|
|
459
|
+
function isPropertyUnused(prop, isTestFile, project) {
|
|
460
|
+
const counts = countPropertyReferences(prop, isTestFile);
|
|
461
|
+
const onlyUsedInTests = counts.nonTestReferences === 1 && counts.testReferences > 0;
|
|
462
|
+
if (counts.totalReferences > 1 && !onlyUsedInTests) {
|
|
463
|
+
const result2 = { isUnusedOrTestOnly: false, onlyUsedInTests: false };
|
|
464
|
+
return result2;
|
|
465
|
+
}
|
|
466
|
+
const equivalentProps = findStructurallyEquivalentProperties(prop, project);
|
|
467
|
+
for (const equivalentProp of equivalentProps) {
|
|
468
|
+
const equivalentCounts = countPropertyReferences(equivalentProp, isTestFile);
|
|
469
|
+
const equivalentOnlyUsedInTests = equivalentCounts.nonTestReferences === 1 && equivalentCounts.testReferences > 0;
|
|
470
|
+
if (equivalentCounts.totalReferences > 1 && !equivalentOnlyUsedInTests) {
|
|
471
|
+
const result2 = { isUnusedOrTestOnly: false, onlyUsedInTests: false };
|
|
472
|
+
return result2;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
const result = { isUnusedOrTestOnly: true, onlyUsedInTests };
|
|
476
|
+
return result;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// src/analyzeInterfaces.ts
|
|
480
|
+
function analyzeInterfaces(sourceFile, tsConfigDir, isTestFile, results, project) {
|
|
481
|
+
const interfaces = sourceFile.getInterfaces();
|
|
482
|
+
for (const iface of interfaces) {
|
|
483
|
+
const interfaceName = iface.getName();
|
|
484
|
+
for (const prop of iface.getProperties()) {
|
|
485
|
+
const usage = isPropertyUnused(prop, isTestFile, project);
|
|
486
|
+
if (usage.isUnusedOrTestOnly) {
|
|
487
|
+
const relativePath = path5.relative(tsConfigDir, sourceFile.getFilePath());
|
|
488
|
+
const todoComment = extractTodoComment(prop);
|
|
489
|
+
let severity = "error";
|
|
490
|
+
if (todoComment) {
|
|
491
|
+
severity = "warning";
|
|
492
|
+
} else if (usage.onlyUsedInTests) {
|
|
493
|
+
severity = "info";
|
|
494
|
+
}
|
|
495
|
+
const propertyName = prop.getName();
|
|
496
|
+
const startPos = prop.getStart();
|
|
497
|
+
const lineStartPos = prop.getStartLinePos();
|
|
498
|
+
const character = startPos - lineStartPos + 1;
|
|
499
|
+
const endCharacter = character + propertyName.length;
|
|
500
|
+
const result = {
|
|
501
|
+
filePath: relativePath,
|
|
502
|
+
typeName: interfaceName,
|
|
503
|
+
propertyName,
|
|
504
|
+
line: prop.getStartLineNumber(),
|
|
505
|
+
character,
|
|
506
|
+
endCharacter,
|
|
507
|
+
todoComment,
|
|
508
|
+
severity,
|
|
509
|
+
onlyUsedInTests: usage.onlyUsedInTests
|
|
510
|
+
};
|
|
511
|
+
results.push(result);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// src/analyzeTypeAliases.ts
|
|
518
|
+
import path6 from "node:path";
|
|
519
|
+
import { Node as Node2 } from "ts-morph";
|
|
520
|
+
function analyzeTypeLiteralMember(member, typeName, sourceFile, tsConfigDir, isTestFile, results, project) {
|
|
521
|
+
if (!Node2.isPropertySignature(member)) {
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
const usage = isPropertyUnused(member, isTestFile, project);
|
|
525
|
+
if (!usage.isUnusedOrTestOnly) {
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
const relativePath = path6.relative(tsConfigDir, sourceFile.getFilePath());
|
|
529
|
+
const todoComment = extractTodoComment(member);
|
|
530
|
+
let severity = "error";
|
|
531
|
+
if (todoComment) {
|
|
532
|
+
severity = "warning";
|
|
533
|
+
} else if (usage.onlyUsedInTests) {
|
|
534
|
+
severity = "info";
|
|
535
|
+
}
|
|
536
|
+
const propertyName = member.getName();
|
|
537
|
+
const startPos = member.getStart();
|
|
538
|
+
const lineStartPos = member.getStartLinePos();
|
|
539
|
+
const character = startPos - lineStartPos + 1;
|
|
540
|
+
const endCharacter = character + propertyName.length;
|
|
541
|
+
const result = {
|
|
542
|
+
filePath: relativePath,
|
|
543
|
+
typeName,
|
|
544
|
+
propertyName,
|
|
545
|
+
line: member.getStartLineNumber(),
|
|
546
|
+
character,
|
|
547
|
+
endCharacter,
|
|
548
|
+
todoComment,
|
|
549
|
+
severity,
|
|
550
|
+
onlyUsedInTests: usage.onlyUsedInTests
|
|
551
|
+
};
|
|
552
|
+
results.push(result);
|
|
553
|
+
}
|
|
554
|
+
function analyzeTypeAlias(typeAlias, sourceFile, tsConfigDir, isTestFile, results, project) {
|
|
555
|
+
const typeName = typeAlias.getName();
|
|
556
|
+
const typeNode = typeAlias.getTypeNode();
|
|
557
|
+
if (!typeNode) {
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
if (!Node2.isTypeLiteral(typeNode)) {
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
for (const member of typeNode.getMembers()) {
|
|
564
|
+
analyzeTypeLiteralMember(member, typeName, sourceFile, tsConfigDir, isTestFile, results, project);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
function analyzeTypeAliases(sourceFile, tsConfigDir, isTestFile, results, project) {
|
|
568
|
+
const typeAliases = sourceFile.getTypeAliases();
|
|
569
|
+
for (const typeAlias of typeAliases) {
|
|
570
|
+
analyzeTypeAlias(typeAlias, sourceFile, tsConfigDir, isTestFile, results, project);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// src/findUnusedProperties.ts
|
|
575
|
+
function findUnusedProperties(project, tsConfigDir, isTestFile, onProgress, targetFilePath) {
|
|
576
|
+
const results = [];
|
|
577
|
+
for (const sourceFile of project.getSourceFiles()) {
|
|
578
|
+
if (isTestFile(sourceFile)) {
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
if (hasNoCheck(sourceFile)) {
|
|
582
|
+
continue;
|
|
583
|
+
}
|
|
584
|
+
if (targetFilePath && sourceFile.getFilePath() !== targetFilePath) {
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
if (onProgress) {
|
|
588
|
+
const relativePath = path7.relative(tsConfigDir, sourceFile.getFilePath());
|
|
589
|
+
onProgress(relativePath);
|
|
590
|
+
}
|
|
591
|
+
analyzeInterfaces(sourceFile, tsConfigDir, isTestFile, results, project);
|
|
592
|
+
analyzeTypeAliases(sourceFile, tsConfigDir, isTestFile, results, project);
|
|
593
|
+
}
|
|
594
|
+
return results;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// src/isTestFile.ts
|
|
598
|
+
var TEST_FILE_EXTENSIONS = [".test.ts", ".test.tsx", ".spec.ts", ".spec.tsx"];
|
|
599
|
+
function isTestFile(sourceFile) {
|
|
600
|
+
const filePath = sourceFile.getFilePath();
|
|
601
|
+
return filePath.includes("__tests__") || TEST_FILE_EXTENSIONS.some((ext) => filePath.endsWith(ext));
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// src/analyzeProject.ts
|
|
605
|
+
function analyzeProject(tsConfigPath, onProgress, targetFilePath, isTestFile2 = isTestFile) {
|
|
606
|
+
const project = new Project({
|
|
607
|
+
tsConfigFilePath: tsConfigPath
|
|
608
|
+
});
|
|
609
|
+
const tsConfigDir = path8.dirname(tsConfigPath);
|
|
610
|
+
const allSourceFiles = project.getSourceFiles();
|
|
611
|
+
const filesToAnalyze = allSourceFiles.filter((sf) => {
|
|
612
|
+
if (isTestFile2(sf)) {
|
|
613
|
+
return false;
|
|
614
|
+
}
|
|
615
|
+
if (hasNoCheck(sf)) {
|
|
616
|
+
return false;
|
|
617
|
+
}
|
|
618
|
+
if (targetFilePath && sf.getFilePath() !== targetFilePath) {
|
|
619
|
+
return false;
|
|
620
|
+
}
|
|
621
|
+
return true;
|
|
622
|
+
});
|
|
623
|
+
const totalFiles = filesToAnalyze.length;
|
|
624
|
+
let currentFile = 0;
|
|
625
|
+
const filesProcessed = new Set;
|
|
626
|
+
const progressCallback = onProgress ? (filePath) => {
|
|
627
|
+
if (!filesProcessed.has(filePath)) {
|
|
628
|
+
filesProcessed.add(filePath);
|
|
629
|
+
currentFile++;
|
|
630
|
+
onProgress(currentFile, totalFiles, filePath);
|
|
631
|
+
}
|
|
632
|
+
} : undefined;
|
|
633
|
+
const unusedExports = findUnusedExports(project, tsConfigDir, isTestFile2, progressCallback, targetFilePath);
|
|
634
|
+
const unusedProperties = findUnusedProperties(project, tsConfigDir, isTestFile2, progressCallback, targetFilePath);
|
|
635
|
+
const neverReturnedTypes = findNeverReturnedTypes(project, tsConfigDir, isTestFile2, progressCallback, targetFilePath);
|
|
636
|
+
const unusedFiles = [];
|
|
637
|
+
const fileExportCounts = new Map;
|
|
638
|
+
for (const sourceFile of filesToAnalyze) {
|
|
639
|
+
const filePath = path8.relative(tsConfigDir, sourceFile.getFilePath());
|
|
640
|
+
const exports = sourceFile.getExportedDeclarations();
|
|
641
|
+
const totalExports = exports.size;
|
|
642
|
+
if (totalExports > 0) {
|
|
643
|
+
fileExportCounts.set(filePath, { total: totalExports, unused: 0, testOnly: 0 });
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
for (const unusedExport of unusedExports) {
|
|
647
|
+
const counts = fileExportCounts.get(unusedExport.filePath);
|
|
648
|
+
if (counts) {
|
|
649
|
+
counts.unused++;
|
|
650
|
+
if (unusedExport.onlyUsedInTests) {
|
|
651
|
+
counts.testOnly++;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
for (const [filePath, counts] of fileExportCounts.entries()) {
|
|
656
|
+
const allExportsUnused = counts.total > 0 && counts.unused === counts.total;
|
|
657
|
+
const hasAnyTestOnlyExports = counts.testOnly > 0;
|
|
658
|
+
if (allExportsUnused && !hasAnyTestOnlyExports) {
|
|
659
|
+
unusedFiles.push(filePath);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
const results = {
|
|
663
|
+
unusedExports,
|
|
664
|
+
unusedProperties,
|
|
665
|
+
unusedFiles,
|
|
666
|
+
neverReturnedTypes
|
|
667
|
+
};
|
|
668
|
+
return results;
|
|
669
|
+
}
|
|
670
|
+
// src/fixProject.ts
|
|
671
|
+
import fs from "node:fs";
|
|
672
|
+
import path10 from "node:path";
|
|
673
|
+
import { Project as Project2, SyntaxKind as SyntaxKind4 } from "ts-morph";
|
|
674
|
+
|
|
675
|
+
// src/checkGitStatus.ts
|
|
676
|
+
import { execSync } from "node:child_process";
|
|
677
|
+
import path9 from "node:path";
|
|
678
|
+
function checkGitStatus(workingDir) {
|
|
679
|
+
const changedFiles = new Set;
|
|
680
|
+
try {
|
|
681
|
+
const gitRoot = execSync("git rev-parse --show-toplevel", {
|
|
682
|
+
cwd: workingDir,
|
|
683
|
+
encoding: "utf-8",
|
|
684
|
+
stdio: ["pipe", "pipe", "ignore"]
|
|
685
|
+
}).trim();
|
|
686
|
+
const output = execSync("git status --porcelain", {
|
|
687
|
+
cwd: workingDir,
|
|
688
|
+
encoding: "utf-8",
|
|
689
|
+
stdio: ["pipe", "pipe", "ignore"]
|
|
690
|
+
});
|
|
691
|
+
const lines = output.trim().split(`
|
|
692
|
+
`);
|
|
693
|
+
for (const line of lines) {
|
|
694
|
+
if (!line)
|
|
695
|
+
continue;
|
|
696
|
+
const filename = line.slice(3).trim();
|
|
697
|
+
const actualFilename = filename.includes(" -> ") ? filename.split(" -> ")[1] : filename;
|
|
698
|
+
if (actualFilename) {
|
|
699
|
+
const absolutePath = path9.resolve(gitRoot, actualFilename);
|
|
700
|
+
changedFiles.add(absolutePath);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
} catch (_error) {
|
|
704
|
+
return changedFiles;
|
|
705
|
+
}
|
|
706
|
+
return changedFiles;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// src/fixProject.ts
|
|
710
|
+
function fixProject(tsConfigPath, onProgress, isTestFile2 = isTestFile) {
|
|
711
|
+
const results = {
|
|
712
|
+
fixedExports: 0,
|
|
713
|
+
fixedProperties: 0,
|
|
714
|
+
fixedNeverReturnedTypes: 0,
|
|
715
|
+
deletedFiles: 0,
|
|
716
|
+
skippedFiles: [],
|
|
717
|
+
errors: []
|
|
718
|
+
};
|
|
719
|
+
const analysis = analyzeProject(tsConfigPath, undefined, undefined, isTestFile2);
|
|
720
|
+
const tsConfigDir = path10.dirname(path10.resolve(tsConfigPath));
|
|
721
|
+
const filesWithChanges = checkGitStatus(tsConfigDir);
|
|
722
|
+
const project = new Project2({
|
|
723
|
+
tsConfigFilePath: tsConfigPath
|
|
724
|
+
});
|
|
725
|
+
const deletedFiles = new Set;
|
|
726
|
+
for (const relativeFilePath of analysis.unusedFiles) {
|
|
727
|
+
const absoluteFilePath = path10.resolve(tsConfigDir, relativeFilePath);
|
|
728
|
+
if (filesWithChanges.has(absoluteFilePath)) {
|
|
729
|
+
onProgress?.(`Skipped: ${relativeFilePath} (has local git changes)`);
|
|
730
|
+
results.skippedFiles.push(relativeFilePath);
|
|
731
|
+
continue;
|
|
732
|
+
}
|
|
733
|
+
try {
|
|
734
|
+
onProgress?.(`Deleting: ${relativeFilePath} (all exports unused)`);
|
|
735
|
+
fs.unlinkSync(absoluteFilePath);
|
|
736
|
+
deletedFiles.add(relativeFilePath);
|
|
737
|
+
results.deletedFiles++;
|
|
738
|
+
} catch (error) {
|
|
739
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
740
|
+
results.errors.push({ file: relativeFilePath, error: errorMessage });
|
|
741
|
+
onProgress?.(`Error deleting ${relativeFilePath}: ${errorMessage}`);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
if (deletedFiles.size > 0) {
|
|
745
|
+
cleanupBrokenImports(project, tsConfigDir, deletedFiles, filesWithChanges, onProgress, results);
|
|
746
|
+
}
|
|
747
|
+
const neverReturnedByFile = new Map;
|
|
748
|
+
for (const neverReturned of analysis.neverReturnedTypes || []) {
|
|
749
|
+
if (neverReturned.severity !== "error") {
|
|
750
|
+
continue;
|
|
751
|
+
}
|
|
752
|
+
if (!neverReturnedByFile.has(neverReturned.filePath)) {
|
|
753
|
+
neverReturnedByFile.set(neverReturned.filePath, []);
|
|
754
|
+
}
|
|
755
|
+
neverReturnedByFile.get(neverReturned.filePath)?.push(neverReturned);
|
|
756
|
+
}
|
|
757
|
+
const unusedExportNames = new Set;
|
|
758
|
+
for (const unusedExport of analysis.unusedExports) {
|
|
759
|
+
if (unusedExport.severity === "error") {
|
|
760
|
+
unusedExportNames.add(`${unusedExport.filePath}:${unusedExport.exportName}`);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
for (const [relativeFilePath, neverReturnedItems] of neverReturnedByFile.entries()) {
|
|
764
|
+
const absoluteFilePath = path10.resolve(tsConfigDir, relativeFilePath);
|
|
765
|
+
if (analysis.unusedFiles.includes(relativeFilePath)) {
|
|
766
|
+
continue;
|
|
767
|
+
}
|
|
768
|
+
if (filesWithChanges.has(absoluteFilePath)) {
|
|
769
|
+
if (!results.skippedFiles.includes(relativeFilePath)) {
|
|
770
|
+
onProgress?.(`Skipped: ${relativeFilePath} (has local git changes)`);
|
|
771
|
+
results.skippedFiles.push(relativeFilePath);
|
|
772
|
+
}
|
|
773
|
+
continue;
|
|
774
|
+
}
|
|
775
|
+
try {
|
|
776
|
+
const sourceFile = project.getSourceFile(absoluteFilePath);
|
|
777
|
+
if (!sourceFile) {
|
|
778
|
+
continue;
|
|
779
|
+
}
|
|
780
|
+
onProgress?.(`Fixing: ${relativeFilePath}`);
|
|
781
|
+
if (neverReturnedItems) {
|
|
782
|
+
for (const neverReturned of neverReturnedItems) {
|
|
783
|
+
const exportKey = `${relativeFilePath}:${neverReturned.functionName}`;
|
|
784
|
+
if (unusedExportNames.has(exportKey)) {
|
|
785
|
+
continue;
|
|
786
|
+
}
|
|
787
|
+
if (removeNeverReturnedType(sourceFile, neverReturned.functionName, neverReturned.neverReturnedType)) {
|
|
788
|
+
onProgress?.(` ✓ Removed never-returned type '${neverReturned.neverReturnedType}' from ${neverReturned.functionName}`);
|
|
789
|
+
results.fixedNeverReturnedTypes++;
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
sourceFile.saveSync();
|
|
794
|
+
} catch (error) {
|
|
795
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
796
|
+
results.errors.push({ file: relativeFilePath, error: errorMessage });
|
|
797
|
+
onProgress?.(`Error fixing ${relativeFilePath}: ${errorMessage}`);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
const exportsByFile = new Map;
|
|
801
|
+
for (const unusedExport of analysis.unusedExports) {
|
|
802
|
+
if (unusedExport.severity !== "error") {
|
|
803
|
+
continue;
|
|
804
|
+
}
|
|
805
|
+
if (!exportsByFile.has(unusedExport.filePath)) {
|
|
806
|
+
exportsByFile.set(unusedExport.filePath, []);
|
|
807
|
+
}
|
|
808
|
+
exportsByFile.get(unusedExport.filePath)?.push(unusedExport);
|
|
809
|
+
}
|
|
810
|
+
for (const [relativeFilePath, exports] of exportsByFile.entries()) {
|
|
811
|
+
const absoluteFilePath = path10.resolve(tsConfigDir, relativeFilePath);
|
|
812
|
+
if (analysis.unusedFiles.includes(relativeFilePath)) {
|
|
813
|
+
continue;
|
|
814
|
+
}
|
|
815
|
+
if (filesWithChanges.has(absoluteFilePath)) {
|
|
816
|
+
onProgress?.(`Skipped: ${relativeFilePath} (has local git changes)`);
|
|
817
|
+
results.skippedFiles.push(relativeFilePath);
|
|
818
|
+
continue;
|
|
819
|
+
}
|
|
820
|
+
try {
|
|
821
|
+
const sourceFile = project.getSourceFile(absoluteFilePath);
|
|
822
|
+
if (!sourceFile) {
|
|
823
|
+
continue;
|
|
824
|
+
}
|
|
825
|
+
if (!neverReturnedByFile.has(relativeFilePath)) {
|
|
826
|
+
onProgress?.(`Fixing: ${relativeFilePath}`);
|
|
827
|
+
}
|
|
828
|
+
for (const unusedExport of exports) {
|
|
829
|
+
if (removeExport(sourceFile, unusedExport.exportName)) {
|
|
830
|
+
onProgress?.(` ✓ Removed unused export: ${unusedExport.exportName}`);
|
|
831
|
+
results.fixedExports++;
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
sourceFile.saveSync();
|
|
835
|
+
} catch (error) {
|
|
836
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
837
|
+
results.errors.push({ file: relativeFilePath, error: errorMessage });
|
|
838
|
+
onProgress?.(`Error fixing ${relativeFilePath}: ${errorMessage}`);
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
const propertiesByFile = new Map;
|
|
842
|
+
for (const unusedProperty of analysis.unusedProperties) {
|
|
843
|
+
if (unusedProperty.severity !== "error") {
|
|
844
|
+
continue;
|
|
845
|
+
}
|
|
846
|
+
if (!propertiesByFile.has(unusedProperty.filePath)) {
|
|
847
|
+
propertiesByFile.set(unusedProperty.filePath, []);
|
|
848
|
+
}
|
|
849
|
+
propertiesByFile.get(unusedProperty.filePath)?.push(unusedProperty);
|
|
850
|
+
}
|
|
851
|
+
for (const [relativeFilePath, properties] of propertiesByFile.entries()) {
|
|
852
|
+
const absoluteFilePath = path10.resolve(tsConfigDir, relativeFilePath);
|
|
853
|
+
if (analysis.unusedFiles.includes(relativeFilePath)) {
|
|
854
|
+
continue;
|
|
855
|
+
}
|
|
856
|
+
if (filesWithChanges.has(absoluteFilePath)) {
|
|
857
|
+
if (!results.skippedFiles.includes(relativeFilePath)) {
|
|
858
|
+
onProgress?.(`Skipped: ${relativeFilePath} (has local git changes)`);
|
|
859
|
+
results.skippedFiles.push(relativeFilePath);
|
|
860
|
+
}
|
|
861
|
+
continue;
|
|
862
|
+
}
|
|
863
|
+
try {
|
|
864
|
+
const sourceFile = project.getSourceFile(absoluteFilePath);
|
|
865
|
+
if (!sourceFile) {
|
|
866
|
+
continue;
|
|
867
|
+
}
|
|
868
|
+
if (!exportsByFile.has(relativeFilePath) && !neverReturnedByFile.has(relativeFilePath)) {
|
|
869
|
+
onProgress?.(`Fixing: ${relativeFilePath}`);
|
|
870
|
+
}
|
|
871
|
+
for (const unusedProperty of properties) {
|
|
872
|
+
if (removeProperty(sourceFile, unusedProperty.typeName, unusedProperty.propertyName)) {
|
|
873
|
+
onProgress?.(` ✓ Removed unused property: ${unusedProperty.typeName}.${unusedProperty.propertyName}`);
|
|
874
|
+
results.fixedProperties++;
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
sourceFile.saveSync();
|
|
878
|
+
} catch (error) {
|
|
879
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
880
|
+
results.errors.push({ file: relativeFilePath, error: errorMessage });
|
|
881
|
+
onProgress?.(`Error fixing ${relativeFilePath}: ${errorMessage}`);
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
return results;
|
|
885
|
+
}
|
|
886
|
+
function removeExport(sourceFile, exportName) {
|
|
887
|
+
const exportedDeclarations = sourceFile.getExportedDeclarations();
|
|
888
|
+
const declarations = exportedDeclarations.get(exportName);
|
|
889
|
+
if (!declarations || declarations.length === 0) {
|
|
890
|
+
return false;
|
|
891
|
+
}
|
|
892
|
+
for (const declaration of declarations) {
|
|
893
|
+
if (declaration.getKind() === SyntaxKind4.VariableDeclaration) {
|
|
894
|
+
const variableStatement = declaration.getFirstAncestorByKind(SyntaxKind4.VariableStatement);
|
|
895
|
+
if (variableStatement) {
|
|
896
|
+
variableStatement.remove();
|
|
897
|
+
continue;
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
if ("remove" in declaration && typeof declaration.remove === "function") {
|
|
901
|
+
declaration.remove();
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
return true;
|
|
905
|
+
}
|
|
906
|
+
function removeProperty(sourceFile, typeName, propertyName) {
|
|
907
|
+
const interfaces = sourceFile.getInterfaces();
|
|
908
|
+
for (const iface of interfaces) {
|
|
909
|
+
if (iface.getName() === typeName) {
|
|
910
|
+
const property = iface.getProperty(propertyName);
|
|
911
|
+
if (property) {
|
|
912
|
+
property.remove();
|
|
913
|
+
return true;
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
const typeAliases = sourceFile.getTypeAliases();
|
|
918
|
+
for (const typeAlias of typeAliases) {
|
|
919
|
+
if (typeAlias.getName() === typeName) {
|
|
920
|
+
const typeNode = typeAlias.getTypeNode();
|
|
921
|
+
if (typeNode && typeNode.getKind() === SyntaxKind4.TypeLiteral) {
|
|
922
|
+
const typeLiteral = typeNode.asKindOrThrow(SyntaxKind4.TypeLiteral);
|
|
923
|
+
const members = typeLiteral.getMembers();
|
|
924
|
+
for (const member of members) {
|
|
925
|
+
if (member.getKind() === SyntaxKind4.PropertySignature) {
|
|
926
|
+
const propSig = member.asKind(SyntaxKind4.PropertySignature);
|
|
927
|
+
if (propSig?.getName() === propertyName) {
|
|
928
|
+
propSig.remove();
|
|
929
|
+
return true;
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
return false;
|
|
937
|
+
}
|
|
938
|
+
function removeNeverReturnedType(sourceFile, functionName, neverReturnedType) {
|
|
939
|
+
const functions = sourceFile.getFunctions();
|
|
940
|
+
for (const func of functions) {
|
|
941
|
+
if (func.getName() === functionName) {
|
|
942
|
+
const returnTypeNode = func.getReturnTypeNode();
|
|
943
|
+
if (!returnTypeNode) {
|
|
944
|
+
return false;
|
|
945
|
+
}
|
|
946
|
+
const returnType = returnTypeNode.getType();
|
|
947
|
+
let typeToCheck = returnType;
|
|
948
|
+
let isPromise = false;
|
|
949
|
+
const symbol = returnType.getSymbol();
|
|
950
|
+
if (symbol?.getName() === "Promise") {
|
|
951
|
+
const typeArgs = returnType.getTypeArguments();
|
|
952
|
+
if (typeArgs.length > 0 && typeArgs[0]) {
|
|
953
|
+
typeToCheck = typeArgs[0];
|
|
954
|
+
isPromise = true;
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
if (!typeToCheck.isUnion()) {
|
|
958
|
+
return false;
|
|
959
|
+
}
|
|
960
|
+
const unionTypes = typeToCheck.getUnionTypes();
|
|
961
|
+
const hasInlineObjectType = unionTypes.some((ut) => ut.getSymbol()?.getName() === "__type");
|
|
962
|
+
const hasEnumLiteral = unionTypes.some((ut) => ut.isEnumLiteral());
|
|
963
|
+
if (hasInlineObjectType || hasEnumLiteral) {
|
|
964
|
+
const originalTypeText = returnTypeNode.getText();
|
|
965
|
+
let typeTextToModify = originalTypeText;
|
|
966
|
+
let promiseWrapper = "";
|
|
967
|
+
if (isPromise) {
|
|
968
|
+
const promiseMatch = originalTypeText.match(/^Promise<(.+)>$/s);
|
|
969
|
+
if (promiseMatch?.[1]) {
|
|
970
|
+
typeTextToModify = promiseMatch[1];
|
|
971
|
+
promiseWrapper = "Promise<>";
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
const branches = splitUnionType(typeTextToModify);
|
|
975
|
+
const normalizedRemove = normalizeTypeText(neverReturnedType === "true" || neverReturnedType === "false" ? "boolean" : neverReturnedType);
|
|
976
|
+
const remainingBranches = branches.filter((branch) => {
|
|
977
|
+
const trimmed = branch.trim();
|
|
978
|
+
const normalized = normalizeTypeText(trimmed === "true" || trimmed === "false" ? "boolean" : trimmed);
|
|
979
|
+
return normalized !== normalizedRemove;
|
|
980
|
+
});
|
|
981
|
+
if (remainingBranches.length === 0 || remainingBranches.length === branches.length) {
|
|
982
|
+
return false;
|
|
983
|
+
}
|
|
984
|
+
let newReturnType = remainingBranches.join(" | ");
|
|
985
|
+
if (promiseWrapper) {
|
|
986
|
+
newReturnType = `Promise<${newReturnType}>`;
|
|
987
|
+
}
|
|
988
|
+
func.setReturnType(newReturnType);
|
|
989
|
+
return true;
|
|
990
|
+
} else {
|
|
991
|
+
const typesToKeep = [];
|
|
992
|
+
for (const ut of unionTypes) {
|
|
993
|
+
const symbol2 = ut.getSymbol();
|
|
994
|
+
const typeName = symbol2?.getName() || ut.getText();
|
|
995
|
+
const normalizedName = typeName === "true" || typeName === "false" ? "boolean" : typeName;
|
|
996
|
+
const normalizedRemove = neverReturnedType === "true" || neverReturnedType === "false" ? "boolean" : neverReturnedType;
|
|
997
|
+
if (normalizedName !== normalizedRemove && !typesToKeep.includes(typeName)) {
|
|
998
|
+
typesToKeep.push(typeName);
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
let newReturnType;
|
|
1002
|
+
if (typesToKeep.length === 1 && typesToKeep[0]) {
|
|
1003
|
+
newReturnType = typesToKeep[0];
|
|
1004
|
+
} else if (typesToKeep.length > 1) {
|
|
1005
|
+
newReturnType = typesToKeep.join(" | ");
|
|
1006
|
+
}
|
|
1007
|
+
if (!newReturnType) {
|
|
1008
|
+
return false;
|
|
1009
|
+
}
|
|
1010
|
+
if (isPromise) {
|
|
1011
|
+
newReturnType = `Promise<${newReturnType}>`;
|
|
1012
|
+
}
|
|
1013
|
+
func.setReturnType(newReturnType);
|
|
1014
|
+
return true;
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
return false;
|
|
1019
|
+
}
|
|
1020
|
+
function splitUnionType(typeText) {
|
|
1021
|
+
const branches = [];
|
|
1022
|
+
let current = "";
|
|
1023
|
+
let depth = 0;
|
|
1024
|
+
let inString = false;
|
|
1025
|
+
let stringChar = "";
|
|
1026
|
+
for (let i = 0;i < typeText.length; i++) {
|
|
1027
|
+
const char = typeText[i];
|
|
1028
|
+
const prevChar = i > 0 ? typeText[i - 1] : "";
|
|
1029
|
+
if ((char === '"' || char === "'") && prevChar !== "\\") {
|
|
1030
|
+
if (!inString) {
|
|
1031
|
+
inString = true;
|
|
1032
|
+
stringChar = char;
|
|
1033
|
+
} else if (char === stringChar) {
|
|
1034
|
+
inString = false;
|
|
1035
|
+
stringChar = "";
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
if (!inString) {
|
|
1039
|
+
if (char === "{" || char === "<" || char === "(") {
|
|
1040
|
+
depth++;
|
|
1041
|
+
} else if (char === "}" || char === ">" || char === ")") {
|
|
1042
|
+
depth--;
|
|
1043
|
+
} else if (char === "|" && depth === 0) {
|
|
1044
|
+
branches.push(current);
|
|
1045
|
+
current = "";
|
|
1046
|
+
continue;
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
current += char;
|
|
1050
|
+
}
|
|
1051
|
+
if (current) {
|
|
1052
|
+
branches.push(current);
|
|
1053
|
+
}
|
|
1054
|
+
return branches;
|
|
1055
|
+
}
|
|
1056
|
+
function normalizeTypeText(typeText) {
|
|
1057
|
+
return typeText.replace(/;\s*([}\]>])/g, " $1").replace(/\s+/g, " ").trim();
|
|
1058
|
+
}
|
|
1059
|
+
function cleanupBrokenImports(project, tsConfigDir, deletedFiles, filesWithChanges, onProgress, results) {
|
|
1060
|
+
const sourceFiles = project.getSourceFiles();
|
|
1061
|
+
for (const sourceFile of sourceFiles) {
|
|
1062
|
+
const relativePath = path10.relative(tsConfigDir, sourceFile.getFilePath());
|
|
1063
|
+
const absolutePath = sourceFile.getFilePath();
|
|
1064
|
+
if (filesWithChanges.has(absolutePath)) {
|
|
1065
|
+
continue;
|
|
1066
|
+
}
|
|
1067
|
+
const exportDeclarations = sourceFile.getExportDeclarations();
|
|
1068
|
+
let hasValidExports = false;
|
|
1069
|
+
const brokenExports = [];
|
|
1070
|
+
for (const exportDecl of exportDeclarations) {
|
|
1071
|
+
const moduleSpecifier = exportDecl.getModuleSpecifierValue();
|
|
1072
|
+
if (moduleSpecifier) {
|
|
1073
|
+
const resolvedPath = resolveImportPath(sourceFile.getFilePath(), moduleSpecifier);
|
|
1074
|
+
const relativeResolvedPath = resolvedPath ? path10.relative(tsConfigDir, resolvedPath) : null;
|
|
1075
|
+
if (relativeResolvedPath && deletedFiles.has(relativeResolvedPath)) {
|
|
1076
|
+
brokenExports.push(exportDecl);
|
|
1077
|
+
} else {
|
|
1078
|
+
hasValidExports = true;
|
|
1079
|
+
}
|
|
1080
|
+
} else {
|
|
1081
|
+
hasValidExports = true;
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
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;
|
|
1085
|
+
if (hasOwnDeclarations) {
|
|
1086
|
+
hasValidExports = true;
|
|
1087
|
+
}
|
|
1088
|
+
if (brokenExports.length > 0 && !hasValidExports) {
|
|
1089
|
+
try {
|
|
1090
|
+
onProgress?.(`Deleting: ${relativePath} (only re-exports from deleted files)`);
|
|
1091
|
+
fs.unlinkSync(absolutePath);
|
|
1092
|
+
if (results) {
|
|
1093
|
+
results.deletedFiles++;
|
|
1094
|
+
}
|
|
1095
|
+
} catch (error) {
|
|
1096
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1097
|
+
if (results) {
|
|
1098
|
+
results.errors.push({ file: relativePath, error: errorMessage });
|
|
1099
|
+
}
|
|
1100
|
+
onProgress?.(`Error deleting ${relativePath}: ${errorMessage}`);
|
|
1101
|
+
}
|
|
1102
|
+
} else if (brokenExports.length > 0) {
|
|
1103
|
+
try {
|
|
1104
|
+
onProgress?.(`Fixing: ${relativePath}`);
|
|
1105
|
+
for (const exportDecl of brokenExports) {
|
|
1106
|
+
const moduleSpec = exportDecl.getModuleSpecifierValue();
|
|
1107
|
+
exportDecl.remove();
|
|
1108
|
+
onProgress?.(` ✓ Removed broken re-export from: ${moduleSpec}`);
|
|
1109
|
+
}
|
|
1110
|
+
sourceFile.saveSync();
|
|
1111
|
+
} catch (error) {
|
|
1112
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1113
|
+
if (results) {
|
|
1114
|
+
results.errors.push({ file: relativePath, error: errorMessage });
|
|
1115
|
+
}
|
|
1116
|
+
onProgress?.(`Error fixing ${relativePath}: ${errorMessage}`);
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
function resolveImportPath(fromFile, importPath) {
|
|
1122
|
+
const fromDir = path10.dirname(fromFile);
|
|
1123
|
+
if (importPath.startsWith(".")) {
|
|
1124
|
+
const resolved = path10.resolve(fromDir, importPath);
|
|
1125
|
+
const extensions = [".ts", ".tsx", ".js", ".jsx"];
|
|
1126
|
+
for (const ext of extensions) {
|
|
1127
|
+
const withExt = resolved + ext;
|
|
1128
|
+
if (fs.existsSync(withExt)) {
|
|
1129
|
+
return withExt;
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
for (const ext of extensions) {
|
|
1133
|
+
const indexFile = path10.join(resolved, `index${ext}`);
|
|
1134
|
+
if (fs.existsSync(indexFile)) {
|
|
1135
|
+
return indexFile;
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
return `${resolved}.ts`;
|
|
1139
|
+
}
|
|
1140
|
+
return null;
|
|
1141
|
+
}
|
|
1142
|
+
// src/formatResults.ts
|
|
1143
|
+
import path11 from "node:path";
|
|
1144
|
+
function getSeverityMarker(severity) {
|
|
1145
|
+
const markers = {
|
|
1146
|
+
error: "[ERROR]",
|
|
1147
|
+
warning: "[WARNING]",
|
|
1148
|
+
info: "[INFO]"
|
|
1149
|
+
};
|
|
1150
|
+
return markers[severity];
|
|
1151
|
+
}
|
|
1152
|
+
function formatExportLine(item) {
|
|
1153
|
+
const marker = getSeverityMarker(item.severity);
|
|
1154
|
+
if (item.onlyUsedInTests) {
|
|
1155
|
+
return ` ${item.exportName}:${item.line}:${item.character}-${item.endCharacter} ${marker} (Used only in tests)`;
|
|
1156
|
+
}
|
|
1157
|
+
return ` ${item.exportName}:${item.line}:${item.character}-${item.endCharacter} ${marker} (Unused ${item.kind})`;
|
|
1158
|
+
}
|
|
1159
|
+
function formatPropertyLine(item) {
|
|
1160
|
+
const status = item.onlyUsedInTests ? "Used only in tests" : "Unused property";
|
|
1161
|
+
const todoSuffix = item.todoComment ? `: [TODO] ${item.todoComment}` : "";
|
|
1162
|
+
const marker = getSeverityMarker(item.severity);
|
|
1163
|
+
return ` ${item.typeName}.${item.propertyName}:${item.line}:${item.character}-${item.endCharacter} ${marker} (${status}${todoSuffix})`;
|
|
1164
|
+
}
|
|
1165
|
+
function formatNeverReturnedLine(item) {
|
|
1166
|
+
const marker = getSeverityMarker(item.severity);
|
|
1167
|
+
return ` ${item.functionName}:${item.line}:${item.character}-${item.endCharacter} ${marker} (Type '${item.neverReturnedType}' in return type is never returned)`;
|
|
1168
|
+
}
|
|
1169
|
+
function formatGroupedItems(items, formatter, tsConfigDir, cwd) {
|
|
1170
|
+
const lines = [];
|
|
1171
|
+
const grouped = groupByFile(items);
|
|
1172
|
+
for (const [filePath, groupItems] of grouped.entries()) {
|
|
1173
|
+
const absolutePath = path11.resolve(tsConfigDir, filePath);
|
|
1174
|
+
const relativePath = path11.relative(cwd, absolutePath);
|
|
1175
|
+
lines.push(relativePath);
|
|
1176
|
+
for (const item of groupItems) {
|
|
1177
|
+
lines.push(formatter(item));
|
|
1178
|
+
}
|
|
1179
|
+
lines.push("");
|
|
1180
|
+
}
|
|
1181
|
+
return lines;
|
|
1182
|
+
}
|
|
1183
|
+
function formatResults(results, tsConfigDir) {
|
|
1184
|
+
const lines = [];
|
|
1185
|
+
const cwd = process.cwd();
|
|
1186
|
+
const unusedExportNames = new Set;
|
|
1187
|
+
for (const exportItem of results.unusedExports) {
|
|
1188
|
+
unusedExportNames.add(exportItem.exportName);
|
|
1189
|
+
}
|
|
1190
|
+
const propertiesToReport = results.unusedProperties.filter((prop) => !unusedExportNames.has(prop.typeName));
|
|
1191
|
+
const unusedFileSet = new Set(results.unusedFiles);
|
|
1192
|
+
const exportsToReport = results.unusedExports.filter((exp) => !unusedFileSet.has(exp.filePath));
|
|
1193
|
+
if (results.unusedFiles.length > 0) {
|
|
1194
|
+
lines.push("Completely Unused Files:");
|
|
1195
|
+
lines.push("");
|
|
1196
|
+
for (const filePath of results.unusedFiles) {
|
|
1197
|
+
const absolutePath = path11.resolve(tsConfigDir, filePath);
|
|
1198
|
+
const relativePath = path11.relative(cwd, absolutePath);
|
|
1199
|
+
lines.push(relativePath);
|
|
1200
|
+
lines.push(" file:1:1-1 [ERROR] (All exports unused - file can be deleted)");
|
|
1201
|
+
lines.push("");
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
if (exportsToReport.length > 0) {
|
|
1205
|
+
lines.push("Unused Exports:");
|
|
1206
|
+
lines.push("");
|
|
1207
|
+
lines.push(...formatGroupedItems(exportsToReport, formatExportLine, tsConfigDir, cwd));
|
|
1208
|
+
}
|
|
1209
|
+
if (propertiesToReport.length > 0) {
|
|
1210
|
+
lines.push("Unused Type/Interface Properties:");
|
|
1211
|
+
lines.push("");
|
|
1212
|
+
lines.push(...formatGroupedItems(propertiesToReport, formatPropertyLine, tsConfigDir, cwd));
|
|
1213
|
+
}
|
|
1214
|
+
const neverReturnedTypes = results.neverReturnedTypes || [];
|
|
1215
|
+
if (neverReturnedTypes.length > 0) {
|
|
1216
|
+
lines.push("Never-Returned Types:");
|
|
1217
|
+
lines.push("");
|
|
1218
|
+
lines.push(...formatGroupedItems(neverReturnedTypes, formatNeverReturnedLine, tsConfigDir, cwd));
|
|
1219
|
+
}
|
|
1220
|
+
if (results.unusedFiles.length === 0 && exportsToReport.length === 0 && propertiesToReport.length === 0 && neverReturnedTypes.length === 0) {
|
|
1221
|
+
lines.push("No unused exports or properties found!");
|
|
1222
|
+
} else {
|
|
1223
|
+
lines.push("Summary:");
|
|
1224
|
+
lines.push(` Completely unused files: ${results.unusedFiles.length}`);
|
|
1225
|
+
lines.push(` Unused exports: ${exportsToReport.length}`);
|
|
1226
|
+
lines.push(` Unused properties: ${propertiesToReport.length}`);
|
|
1227
|
+
lines.push(` Never-returned types: ${neverReturnedTypes.length}`);
|
|
1228
|
+
}
|
|
1229
|
+
return lines.join(`
|
|
1230
|
+
`);
|
|
1231
|
+
}
|
|
1232
|
+
function groupByFile(items) {
|
|
1233
|
+
const grouped = new Map;
|
|
1234
|
+
for (const item of items) {
|
|
1235
|
+
const existing = grouped.get(item.filePath);
|
|
1236
|
+
if (existing) {
|
|
1237
|
+
existing.push(item);
|
|
1238
|
+
} else {
|
|
1239
|
+
grouped.set(item.filePath, [item]);
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
return grouped;
|
|
1243
|
+
}
|
|
1244
|
+
export {
|
|
1245
|
+
isTestFile,
|
|
1246
|
+
formatResults,
|
|
1247
|
+
fixProject,
|
|
1248
|
+
findUnusedProperties,
|
|
1249
|
+
findUnusedExports,
|
|
1250
|
+
analyzeProject
|
|
1251
|
+
};
|