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,131 @@
1
+ import path from "node:path";
2
+ import { Project } from "ts-morph";
3
+ import { defaultConfig } from "./config";
4
+ import { findNeverReturnedTypes } from "./findNeverReturnedTypes";
5
+ import { findUnusedExports } from "./findUnusedExports";
6
+ import { findUnusedProperties } from "./findUnusedProperties";
7
+ import { hasNoCheck } from "./hasNoCheck";
8
+ import { createIsTestFile, isTestFile as defaultIsTestFile } from "./isTestFile";
9
+ import { matchesFilePattern } from "./patternMatcher";
10
+ export function analyzeProject(tsConfigPath, onProgress, targetFilePath, isTestFileOrOptions) {
11
+ // Handle backward compatibility: can pass IsTestFileFn directly or options object
12
+ let options = {};
13
+ if (typeof isTestFileOrOptions === "function") {
14
+ options = { isTestFile: isTestFileOrOptions };
15
+ }
16
+ else if (isTestFileOrOptions) {
17
+ options = isTestFileOrOptions;
18
+ }
19
+ const config = {
20
+ ...defaultConfig,
21
+ ...options.config,
22
+ };
23
+ // Determine the isTestFile function
24
+ const isTestFile = options.isTestFile
25
+ ? options.isTestFile
26
+ : config.testFilePatterns
27
+ ? createIsTestFile(config.testFilePatterns)
28
+ : defaultIsTestFile;
29
+ const project = new Project({
30
+ tsConfigFilePath: tsConfigPath,
31
+ });
32
+ const tsConfigDir = path.dirname(tsConfigPath);
33
+ // Get total file count for progress reporting
34
+ const allSourceFiles = project.getSourceFiles();
35
+ const filesToAnalyze = allSourceFiles.filter((sf) => {
36
+ if (isTestFile(sf)) {
37
+ return false;
38
+ }
39
+ if (hasNoCheck(sf)) {
40
+ return false;
41
+ }
42
+ if (targetFilePath && sf.getFilePath() !== targetFilePath) {
43
+ return false;
44
+ }
45
+ // Check if file should be ignored based on patterns
46
+ if (config.ignoreFilePatterns.length > 0 && matchesFilePattern(sf.getFilePath(), config.ignoreFilePatterns)) {
47
+ return false;
48
+ }
49
+ return true;
50
+ });
51
+ const totalFiles = filesToAnalyze.length;
52
+ let currentFile = 0;
53
+ const filesProcessed = new Set();
54
+ const progressCallback = onProgress
55
+ ? (filePath) => {
56
+ // Only increment once per file (called from both exports and properties analysis)
57
+ if (!filesProcessed.has(filePath)) {
58
+ filesProcessed.add(filePath);
59
+ currentFile++;
60
+ onProgress(currentFile, totalFiles, filePath);
61
+ }
62
+ }
63
+ : undefined;
64
+ // Build options for child functions
65
+ const exportOptions = {
66
+ ignoreFilePatterns: config.ignoreFilePatterns,
67
+ ignoreExports: config.ignoreExports,
68
+ ignoreModuleAugmentations: config.ignoreModuleAugmentations,
69
+ };
70
+ const propertyOptions = {
71
+ ignoreFilePatterns: config.ignoreFilePatterns,
72
+ ignoreProperties: config.ignoreProperties,
73
+ ignoreTypes: config.ignoreTypes,
74
+ };
75
+ const neverReturnedOptions = {
76
+ ignoreFilePatterns: config.ignoreFilePatterns,
77
+ };
78
+ // Analyze based on config flags
79
+ const unusedExports = config.analyzeExports
80
+ ? findUnusedExports(project, tsConfigDir, isTestFile, progressCallback, targetFilePath, exportOptions)
81
+ : [];
82
+ const unusedProperties = config.analyzeProperties
83
+ ? findUnusedProperties(project, tsConfigDir, isTestFile, progressCallback, targetFilePath, propertyOptions)
84
+ : [];
85
+ const neverReturnedTypes = config.analyzeNeverReturnedTypes
86
+ ? findNeverReturnedTypes(project, tsConfigDir, isTestFile, progressCallback, targetFilePath, neverReturnedOptions)
87
+ : [];
88
+ // Identify completely unused files (where all exports are unused)
89
+ const unusedFiles = [];
90
+ if (config.detectUnusedFiles) {
91
+ const fileExportCounts = new Map();
92
+ // Count total exports per file
93
+ for (const sourceFile of filesToAnalyze) {
94
+ const filePath = path.relative(tsConfigDir, sourceFile.getFilePath());
95
+ const exports = sourceFile.getExportedDeclarations();
96
+ const totalExports = exports.size;
97
+ if (totalExports > 0) {
98
+ fileExportCounts.set(filePath, { total: totalExports, unused: 0, testOnly: 0 });
99
+ }
100
+ }
101
+ // Count unused exports per file
102
+ for (const unusedExport of unusedExports) {
103
+ const counts = fileExportCounts.get(unusedExport.filePath);
104
+ if (counts) {
105
+ counts.unused++;
106
+ if (unusedExport.onlyUsedInTests) {
107
+ counts.testOnly++;
108
+ }
109
+ }
110
+ }
111
+ // Identify files where all exports are unused AND not just test-only
112
+ // Files with any test-only exports should show individual exports with [INFO], not be listed as completely unused
113
+ for (const [filePath, counts] of fileExportCounts.entries()) {
114
+ // Only add to unusedFiles if:
115
+ // 1. All exports are "unused" (in the unusedExports list)
116
+ // 2. NONE of the exports are test-only (all are completely unused with severity: error)
117
+ const allExportsUnused = counts.total > 0 && counts.unused === counts.total;
118
+ const hasAnyTestOnlyExports = counts.testOnly > 0;
119
+ if (allExportsUnused && !hasAnyTestOnlyExports) {
120
+ unusedFiles.push(filePath);
121
+ }
122
+ }
123
+ }
124
+ const results = {
125
+ unusedExports,
126
+ unusedProperties,
127
+ unusedFiles,
128
+ neverReturnedTypes,
129
+ };
130
+ return results;
131
+ }
@@ -0,0 +1,9 @@
1
+ import { type Project, type SourceFile, type TypeAliasDeclaration, type TypeElementTypes } from "ts-morph";
2
+ import type { IsTestFileFn, UnusedPropertyResult } from "./types";
3
+ export interface AnalyzeTypeAliasesOptions {
4
+ ignoreProperties?: string[];
5
+ ignoreTypes?: string[];
6
+ }
7
+ export declare function analyzeTypeLiteralMember(member: TypeElementTypes, typeName: string, sourceFile: SourceFile, tsConfigDir: string, isTestFile: IsTestFileFn, results: UnusedPropertyResult[], project: Project, options?: AnalyzeTypeAliasesOptions): void;
8
+ export declare function analyzeTypeAlias(typeAlias: TypeAliasDeclaration, sourceFile: SourceFile, tsConfigDir: string, isTestFile: IsTestFileFn, results: UnusedPropertyResult[], project: Project, options?: AnalyzeTypeAliasesOptions): void;
9
+ export declare function analyzeTypeAliases(sourceFile: SourceFile, tsConfigDir: string, isTestFile: IsTestFileFn, results: UnusedPropertyResult[], project: Project, options?: AnalyzeTypeAliasesOptions): void;
@@ -0,0 +1,70 @@
1
+ import path from "node:path";
2
+ import { Node } from "ts-morph";
3
+ import { extractTodoComment } from "./extractTodoComment";
4
+ import { isPropertyUnused } from "./isPropertyUnused";
5
+ import { matchesPattern } from "./patternMatcher";
6
+ export function analyzeTypeLiteralMember(member, typeName, sourceFile, tsConfigDir, isTestFile, results, project, options = {}) {
7
+ const { ignoreProperties = [] } = options;
8
+ if (!Node.isPropertySignature(member)) {
9
+ return;
10
+ }
11
+ const propertyName = member.getName();
12
+ // Skip ignored properties
13
+ if (ignoreProperties.length > 0 && matchesPattern(propertyName, ignoreProperties)) {
14
+ return;
15
+ }
16
+ const usage = isPropertyUnused(member, isTestFile, project);
17
+ if (!usage.isUnusedOrTestOnly) {
18
+ return;
19
+ }
20
+ const relativePath = path.relative(tsConfigDir, sourceFile.getFilePath());
21
+ const todoComment = extractTodoComment(member);
22
+ // Determine severity: warning for TODO, info for test-only, error for completely unused
23
+ let severity = "error";
24
+ if (todoComment) {
25
+ severity = "warning";
26
+ }
27
+ else if (usage.onlyUsedInTests) {
28
+ severity = "info";
29
+ }
30
+ const startPos = member.getStart();
31
+ const lineStartPos = member.getStartLinePos();
32
+ const character = startPos - lineStartPos + 1;
33
+ const endCharacter = character + propertyName.length;
34
+ const result = {
35
+ filePath: relativePath,
36
+ typeName,
37
+ propertyName,
38
+ line: member.getStartLineNumber(),
39
+ character,
40
+ endCharacter,
41
+ todoComment,
42
+ severity,
43
+ onlyUsedInTests: usage.onlyUsedInTests,
44
+ };
45
+ results.push(result);
46
+ }
47
+ export function analyzeTypeAlias(typeAlias, sourceFile, tsConfigDir, isTestFile, results, project, options = {}) {
48
+ const { ignoreTypes = [] } = options;
49
+ const typeName = typeAlias.getName();
50
+ // Skip ignored types
51
+ if (ignoreTypes.length > 0 && matchesPattern(typeName, ignoreTypes)) {
52
+ return;
53
+ }
54
+ const typeNode = typeAlias.getTypeNode();
55
+ if (!typeNode) {
56
+ return;
57
+ }
58
+ if (!Node.isTypeLiteral(typeNode)) {
59
+ return;
60
+ }
61
+ for (const member of typeNode.getMembers()) {
62
+ analyzeTypeLiteralMember(member, typeName, sourceFile, tsConfigDir, isTestFile, results, project, options);
63
+ }
64
+ }
65
+ export function analyzeTypeAliases(sourceFile, tsConfigDir, isTestFile, results, project, options = {}) {
66
+ const typeAliases = sourceFile.getTypeAliases();
67
+ for (const typeAlias of typeAliases) {
68
+ analyzeTypeAlias(typeAlias, sourceFile, tsConfigDir, isTestFile, results, project, options);
69
+ }
70
+ }
@@ -0,0 +1,7 @@
1
+ import { Node, type SourceFile } from "ts-morph";
2
+ import type { IsTestFileFn, UnusedExportResult } from "./types";
3
+ export interface CheckExportOptions {
4
+ ignoreExports?: string[];
5
+ ignoreModuleAugmentations?: boolean;
6
+ }
7
+ export declare function checkExportUsage(exportName: string, declarations: readonly Node[], sourceFile: SourceFile, tsConfigDir: string, isTestFile: IsTestFileFn, options?: CheckExportOptions): UnusedExportResult | null;
@@ -0,0 +1,126 @@
1
+ import path from "node:path";
2
+ import { Node } from "ts-morph";
3
+ import { matchesPattern } from "./patternMatcher";
4
+ function getExportKind(declaration) {
5
+ if (Node.isFunctionDeclaration(declaration)) {
6
+ return "function";
7
+ }
8
+ if (Node.isClassDeclaration(declaration)) {
9
+ return "class";
10
+ }
11
+ if (Node.isInterfaceDeclaration(declaration)) {
12
+ return "interface";
13
+ }
14
+ if (Node.isTypeAliasDeclaration(declaration)) {
15
+ return "type";
16
+ }
17
+ if (Node.isEnumDeclaration(declaration)) {
18
+ return "enum";
19
+ }
20
+ if (Node.isModuleDeclaration(declaration)) {
21
+ return "namespace";
22
+ }
23
+ if (Node.isVariableDeclaration(declaration)) {
24
+ const parent = declaration.getParent();
25
+ if (Node.isVariableDeclarationList(parent)) {
26
+ const declarationKind = parent.getDeclarationKind();
27
+ if (declarationKind === "const") {
28
+ return "const";
29
+ }
30
+ }
31
+ return "variable";
32
+ }
33
+ return "export";
34
+ }
35
+ function getNameNode(declaration) {
36
+ if (Node.isFunctionDeclaration(declaration) ||
37
+ Node.isClassDeclaration(declaration) ||
38
+ Node.isInterfaceDeclaration(declaration) ||
39
+ Node.isTypeAliasDeclaration(declaration) ||
40
+ Node.isEnumDeclaration(declaration) ||
41
+ Node.isVariableDeclaration(declaration)) {
42
+ return declaration.getNameNode();
43
+ }
44
+ return undefined;
45
+ }
46
+ /**
47
+ * Checks if a declaration is a module augmentation (declare module "...").
48
+ */
49
+ function isModuleAugmentation(declaration) {
50
+ if (Node.isModuleDeclaration(declaration)) {
51
+ // Module augmentations have a string literal as name (e.g., "library-name")
52
+ const nameNode = declaration.getNameNode();
53
+ if (Node.isStringLiteral(nameNode)) {
54
+ return true;
55
+ }
56
+ }
57
+ return false;
58
+ }
59
+ export function checkExportUsage(exportName, declarations, sourceFile, tsConfigDir, isTestFile, options = {}) {
60
+ const { ignoreExports = [], ignoreModuleAugmentations = true } = options;
61
+ const firstDeclaration = declarations[0];
62
+ if (!firstDeclaration) {
63
+ return null;
64
+ }
65
+ // Check if export should be ignored
66
+ if (ignoreExports.length > 0 && matchesPattern(exportName, ignoreExports)) {
67
+ return null;
68
+ }
69
+ // Check if this is a module augmentation that should be ignored
70
+ if (ignoreModuleAugmentations && isModuleAugmentation(firstDeclaration)) {
71
+ return null;
72
+ }
73
+ // Only report symbols defined in this file, not re-exports
74
+ const declarationSourceFile = firstDeclaration.getSourceFile();
75
+ if (declarationSourceFile.getFilePath() !== sourceFile.getFilePath()) {
76
+ return null;
77
+ }
78
+ if (!Node.isReferenceFindable(firstDeclaration)) {
79
+ return null;
80
+ }
81
+ const references = firstDeclaration.findReferences();
82
+ // Count references by type
83
+ let totalReferences = 0;
84
+ let testReferences = 0;
85
+ let nonTestReferences = 0;
86
+ for (const refGroup of references) {
87
+ const refs = refGroup.getReferences();
88
+ for (const ref of refs) {
89
+ const refSourceFile = ref.getSourceFile();
90
+ totalReferences++;
91
+ if (isTestFile(refSourceFile)) {
92
+ testReferences++;
93
+ }
94
+ else {
95
+ nonTestReferences++;
96
+ }
97
+ }
98
+ }
99
+ // An export is unused if it only has 1 reference (the definition itself)
100
+ const onlyUsedInTests = nonTestReferences === 1 && testReferences > 0;
101
+ if (totalReferences > 1 && !onlyUsedInTests) {
102
+ return null; // Used in production code
103
+ }
104
+ const kind = getExportKind(firstDeclaration);
105
+ const relativePath = path.relative(tsConfigDir, sourceFile.getFilePath());
106
+ // Determine severity: info for test-only, error for completely unused
107
+ const severity = onlyUsedInTests ? "info" : "error";
108
+ // Get the name node for accurate position highlighting
109
+ const nameNode = getNameNode(firstDeclaration);
110
+ const positionNode = nameNode || firstDeclaration;
111
+ const startPos = positionNode.getStart();
112
+ const lineStartPos = positionNode.getStartLinePos();
113
+ const character = startPos - lineStartPos + 1;
114
+ const endCharacter = character + exportName.length;
115
+ const result = {
116
+ filePath: relativePath,
117
+ exportName,
118
+ line: firstDeclaration.getStartLineNumber(),
119
+ character,
120
+ endCharacter,
121
+ kind,
122
+ severity,
123
+ onlyUsedInTests,
124
+ };
125
+ return result;
126
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Check which files have local git changes (modified, staged, or untracked)
3
+ * Note: Checks the entire git repository, not just the working directory
4
+ * @param workingDir - The directory to check git status in (used to find git root)
5
+ * @returns Set of absolute file paths that have local changes
6
+ */
7
+ export declare function checkGitStatus(workingDir: string): Set<string>;
@@ -0,0 +1,49 @@
1
+ import { execSync } from "node:child_process";
2
+ import path from "node:path";
3
+ /**
4
+ * Check which files have local git changes (modified, staged, or untracked)
5
+ * Note: Checks the entire git repository, not just the working directory
6
+ * @param workingDir - The directory to check git status in (used to find git root)
7
+ * @returns Set of absolute file paths that have local changes
8
+ */
9
+ export function checkGitStatus(workingDir) {
10
+ const changedFiles = new Set();
11
+ try {
12
+ // Get the git root directory first
13
+ // git status --porcelain returns paths relative to git root, not cwd
14
+ const gitRoot = execSync("git rev-parse --show-toplevel", {
15
+ cwd: workingDir,
16
+ encoding: "utf-8",
17
+ stdio: ["pipe", "pipe", "ignore"],
18
+ }).trim();
19
+ // Run git status --porcelain to get list of changed files
20
+ // --porcelain gives machine-readable output
21
+ const output = execSync("git status --porcelain", {
22
+ cwd: workingDir,
23
+ encoding: "utf-8",
24
+ stdio: ["pipe", "pipe", "ignore"], // Ignore stderr to avoid errors when not in git repo
25
+ });
26
+ // Parse the output
27
+ // Format: "XY filename" where X is staged status, Y is unstaged status
28
+ const lines = output.trim().split("\n");
29
+ for (const line of lines) {
30
+ if (!line)
31
+ continue;
32
+ // Extract filename (skip first 3 characters which are status codes and space)
33
+ const filename = line.slice(3).trim();
34
+ // Handle renamed files (format: "old -> new")
35
+ const actualFilename = filename.includes(" -> ") ? filename.split(" -> ")[1] : filename;
36
+ if (actualFilename) {
37
+ // Git status returns paths relative to git root, not cwd
38
+ const absolutePath = path.resolve(gitRoot, actualFilename);
39
+ changedFiles.add(absolutePath);
40
+ }
41
+ }
42
+ }
43
+ catch (_error) {
44
+ // If git command fails (e.g., not in a git repo), return empty set
45
+ // This allows the tool to work in non-git directories
46
+ return changedFiles;
47
+ }
48
+ return changedFiles;
49
+ }