analyze-codebase 1.2.2 → 1.3.1
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/.claude/settings.local.json +16 -0
- package/.vscode/settings.json +5 -0
- package/CHANGELOG.md +137 -0
- package/ENHANCEMENTS.md +257 -0
- package/FEATURES.md +225 -0
- package/dist/analyze/i18n/index.js +477 -0
- package/dist/analyze/index.js +187 -25
- package/dist/index.js +203 -16
- package/dist/utils/__tests__/config.test.js +94 -0
- package/dist/utils/__tests__/progress.test.js +37 -0
- package/dist/utils/cancellation.js +75 -0
- package/dist/utils/config.js +83 -0
- package/dist/utils/export.js +218 -0
- package/dist/utils/output.js +137 -41
- package/dist/utils/progress.js +51 -0
- package/dist/utils/spinner.js +61 -0
- package/dist/utils/watch.js +99 -0
- package/docs/agents/developer-agent.md +818 -0
- package/docs/agents/research-agent.md +244 -0
- package/package.json +44 -20
- package/readme.md +163 -86
- package/tsconfig.json +5 -2
- package/vitest.config.ts +12 -0
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || function (mod) {
|
|
19
|
+
if (mod && mod.__esModule) return mod;
|
|
20
|
+
var result = {};
|
|
21
|
+
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
|
|
22
|
+
__setModuleDefault(result, mod);
|
|
23
|
+
return result;
|
|
24
|
+
};
|
|
25
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
26
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
27
|
+
};
|
|
28
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
29
|
+
exports.analyzeAndRemoveUnusedKeys = exports.findUnusedKeys = exports.flattenKeys = void 0;
|
|
30
|
+
const fs = __importStar(require("fs/promises"));
|
|
31
|
+
const path = __importStar(require("path"));
|
|
32
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
33
|
+
const fast_glob_1 = __importDefault(require("fast-glob"));
|
|
34
|
+
const readline = __importStar(require("readline"));
|
|
35
|
+
const progress_1 = require("../../utils/progress");
|
|
36
|
+
const spinner_1 = require("../../utils/spinner");
|
|
37
|
+
const cancellation_1 = require("../../utils/cancellation");
|
|
38
|
+
/**
|
|
39
|
+
* Flattens a nested JSON object into dot-notation paths
|
|
40
|
+
*/
|
|
41
|
+
const flattenKeys = (obj, prefix = "", originalPath = []) => {
|
|
42
|
+
const keys = [];
|
|
43
|
+
for (const key in obj) {
|
|
44
|
+
if (obj.hasOwnProperty(key)) {
|
|
45
|
+
const currentPath = prefix ? `${prefix}.${key}` : key;
|
|
46
|
+
const currentOriginalPath = [...originalPath, key];
|
|
47
|
+
const value = obj[key];
|
|
48
|
+
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
49
|
+
// Recursively flatten nested objects
|
|
50
|
+
keys.push(...(0, exports.flattenKeys)(value, currentPath, currentOriginalPath));
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
// Leaf node - this is a translation key
|
|
54
|
+
keys.push({
|
|
55
|
+
path: currentPath,
|
|
56
|
+
value,
|
|
57
|
+
originalPath: currentOriginalPath,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return keys;
|
|
63
|
+
};
|
|
64
|
+
exports.flattenKeys = flattenKeys;
|
|
65
|
+
/**
|
|
66
|
+
* Checks if a key is a parent of another key
|
|
67
|
+
* e.g., "a.b" is a parent of "a.b.c"
|
|
68
|
+
*/
|
|
69
|
+
const isParentKey = (parentKey, childKey) => {
|
|
70
|
+
if (!childKey.startsWith(parentKey))
|
|
71
|
+
return false;
|
|
72
|
+
// Check if the next character is a dot (to ensure it's a proper parent)
|
|
73
|
+
return childKey.length === parentKey.length || childKey[parentKey.length] === '.';
|
|
74
|
+
};
|
|
75
|
+
/**
|
|
76
|
+
* Searches for a translation key in file content
|
|
77
|
+
* Handles various i18n usage patterns:
|
|
78
|
+
* - t('key')
|
|
79
|
+
* - t("key")
|
|
80
|
+
* - t(`key`)
|
|
81
|
+
* - t(`a.b.${variable}`) - Dynamic keys with template literals
|
|
82
|
+
* - t('a.b.' + variable) - Dynamic keys with string concatenation
|
|
83
|
+
* - i18n.t('key')
|
|
84
|
+
* - $t('key')
|
|
85
|
+
* - {t('key')}
|
|
86
|
+
* - translate('key')
|
|
87
|
+
*/
|
|
88
|
+
const searchKeyInContent = (content, key, allKeys) => {
|
|
89
|
+
// Escape special regex characters in the key
|
|
90
|
+
const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
91
|
+
// Patterns to match static i18n usage:
|
|
92
|
+
// - t('key'), t("key"), t(`key`)
|
|
93
|
+
// - i18n.t('key')
|
|
94
|
+
// - $t('key')
|
|
95
|
+
// - translate('key')
|
|
96
|
+
// - ['key'], ["key"], [`key`] (for array access)
|
|
97
|
+
// - .key (for object access)
|
|
98
|
+
const staticPatterns = [
|
|
99
|
+
// Function calls: t('key'), i18n.t('key'), $t('key'), translate('key')
|
|
100
|
+
new RegExp(`(?:t|i18n\\.t|\\$t|translate)\\s*\\(\\s*['"\`]${escapedKey}['"\`]`, "g"),
|
|
101
|
+
// Array access: ['key'], ["key"], [`key`]
|
|
102
|
+
new RegExp(`\\[\\s*['"\`]${escapedKey}['"\`]\\s*\\]`, "g"),
|
|
103
|
+
// Object access: .key (but not as part of another key)
|
|
104
|
+
new RegExp(`\\.${escapedKey}(?:\\s|;|,|\\)|\\]|\\}|\\n|$)`, "g"),
|
|
105
|
+
// String contains the full key path
|
|
106
|
+
new RegExp(`['"\`]${escapedKey}['"\`]`, "g"),
|
|
107
|
+
];
|
|
108
|
+
// Check for static matches first
|
|
109
|
+
if (staticPatterns.some((pattern) => pattern.test(content))) {
|
|
110
|
+
return { found: true, isDynamic: false };
|
|
111
|
+
}
|
|
112
|
+
// Check for dynamic patterns (template literals with interpolation)
|
|
113
|
+
// Pattern: t(`a.b.${variable}`) or t(`a.b.${var1}.${var2}`)
|
|
114
|
+
// We need to check if the key is a parent of any dynamically constructed key
|
|
115
|
+
const keyParts = key.split('.');
|
|
116
|
+
// Build patterns for dynamic key construction
|
|
117
|
+
// Match: t(`prefix.${variable}`) where prefix matches our key or is a parent
|
|
118
|
+
for (let i = 0; i < keyParts.length; i++) {
|
|
119
|
+
const partialKey = keyParts.slice(0, i + 1).join('.');
|
|
120
|
+
const escapedPartialKey = partialKey.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
121
|
+
// Pattern for template literals: t(`a.b.${...}`)
|
|
122
|
+
// Escape backticks properly in regex
|
|
123
|
+
const templateLiteralPattern = new RegExp("(?:t|i18n\\.t|\\$t|translate)\\s*\\(\\s*`[^`]*" + escapedPartialKey + "[^`]*\\$\\{[^}]+\\}[^`]*`", "g");
|
|
124
|
+
// Pattern for string concatenation: t('a.b.' + variable) or t("a.b." + variable)
|
|
125
|
+
const concatPattern1 = new RegExp(`(?:t|i18n\\.t|\\$t|translate)\\s*\\(\\s*['"\`]${escapedPartialKey}\\.?['"\`]\\s*\\+`, "g");
|
|
126
|
+
// Pattern for reverse concatenation: t(variable + '.a.b')
|
|
127
|
+
const concatPattern2 = new RegExp(`(?:t|i18n\\.t|\\$t|translate)\\s*\\([^)]*\\+\\s*['"\`]\\.?${escapedPartialKey}['"\`]`, "g");
|
|
128
|
+
if (templateLiteralPattern.test(content) ||
|
|
129
|
+
concatPattern1.test(content) ||
|
|
130
|
+
concatPattern2.test(content)) {
|
|
131
|
+
// If we found a dynamic pattern that matches this key or its parent,
|
|
132
|
+
// mark this key and all its children as potentially used
|
|
133
|
+
return { found: true, isDynamic: true };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
// Check if this key is a child of any dynamically used parent key
|
|
137
|
+
// This handles cases where we have t(`a.b.${var}`) and need to mark a.b.c, a.b.d, etc. as used
|
|
138
|
+
// Check all possible parent keys of the current key
|
|
139
|
+
const currentKeyParts = key.split('.');
|
|
140
|
+
for (let i = 1; i < currentKeyParts.length; i++) {
|
|
141
|
+
const parentKey = currentKeyParts.slice(0, i).join('.');
|
|
142
|
+
const escapedParentKey = parentKey.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
143
|
+
// Check if parent key is used in dynamic patterns
|
|
144
|
+
const dynamicPattern = new RegExp("(?:t|i18n\\.t|\\$t|translate)\\s*\\(\\s*`[^`]*" + escapedParentKey + "[^`]*\\$\\{[^}]+\\}", "g");
|
|
145
|
+
const concatPattern = new RegExp("(?:t|i18n\\.t|\\$t|translate)\\s*\\(\\s*['\"`]" + escapedParentKey + "\\.?['\"`]\\s*\\+", "g");
|
|
146
|
+
if (dynamicPattern.test(content) || concatPattern.test(content)) {
|
|
147
|
+
return { found: true, isDynamic: true };
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return { found: false, isDynamic: false };
|
|
151
|
+
};
|
|
152
|
+
/**
|
|
153
|
+
* Collects all files to analyze
|
|
154
|
+
*/
|
|
155
|
+
const collectFiles = async (directory, extensions, exclude) => {
|
|
156
|
+
const patterns = [];
|
|
157
|
+
const ignorePatterns = [
|
|
158
|
+
"**/node_modules/**",
|
|
159
|
+
"**/dist/**",
|
|
160
|
+
"**/build/**",
|
|
161
|
+
"**/coverage/**",
|
|
162
|
+
"**/.git/**",
|
|
163
|
+
"**/.next/**",
|
|
164
|
+
"**/public/**",
|
|
165
|
+
"**/test/**",
|
|
166
|
+
"**/tests/**",
|
|
167
|
+
"**/mocks/**",
|
|
168
|
+
];
|
|
169
|
+
if (exclude) {
|
|
170
|
+
ignorePatterns.push(...exclude.map((ex) => `**/${ex}/**`));
|
|
171
|
+
}
|
|
172
|
+
const fileExtensions = extensions || [".ts", ".tsx", ".js", ".jsx", ".vue"];
|
|
173
|
+
patterns.push(...fileExtensions.map((ext) => `**/*${ext}`));
|
|
174
|
+
const files = await (0, fast_glob_1.default)(patterns, {
|
|
175
|
+
cwd: directory,
|
|
176
|
+
ignore: ignorePatterns,
|
|
177
|
+
absolute: true,
|
|
178
|
+
onlyFiles: true,
|
|
179
|
+
dot: false,
|
|
180
|
+
});
|
|
181
|
+
return files;
|
|
182
|
+
};
|
|
183
|
+
/**
|
|
184
|
+
* Searches the codebase for usage of translation keys
|
|
185
|
+
*/
|
|
186
|
+
const findUnusedKeys = async (options) => {
|
|
187
|
+
const { directory, i18nFile, extensions, exclude, maxConcurrency: providedConcurrency } = options;
|
|
188
|
+
const cancellationToken = new cancellation_1.CancellationToken();
|
|
189
|
+
// Read and parse the i18n file
|
|
190
|
+
const i18nFilePath = path.isAbsolute(i18nFile)
|
|
191
|
+
? i18nFile
|
|
192
|
+
: path.join(directory, i18nFile);
|
|
193
|
+
const spinner = (0, spinner_1.createSpinner)("📖 Loading translation file...");
|
|
194
|
+
// Cleanup on cancellation
|
|
195
|
+
cancellationToken.onCancel(() => {
|
|
196
|
+
spinner.stop();
|
|
197
|
+
});
|
|
198
|
+
let i18nContent;
|
|
199
|
+
try {
|
|
200
|
+
const fileContent = await fs.readFile(i18nFilePath, "utf-8");
|
|
201
|
+
i18nContent = JSON.parse(fileContent);
|
|
202
|
+
spinner.succeed("✅ Translation file loaded");
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
spinner.fail(`❌ Failed to read translation file`);
|
|
206
|
+
throw new Error(`Failed to read or parse i18n file: ${i18nFilePath}. ${error}`);
|
|
207
|
+
}
|
|
208
|
+
// Flatten all keys
|
|
209
|
+
const allKeys = (0, exports.flattenKeys)(i18nContent);
|
|
210
|
+
// Show key count with animation
|
|
211
|
+
const keySpinner = (0, spinner_1.createSpinner)(`🔍 Found ${chalk_1.default.cyan(allKeys.length)} translation keys to check...`);
|
|
212
|
+
await new Promise((resolve) => setTimeout(resolve, 500)); // Brief animation
|
|
213
|
+
keySpinner.succeed(`✅ Found ${chalk_1.default.cyan(allKeys.length)} translation keys to check`);
|
|
214
|
+
// Collect files
|
|
215
|
+
const fileSpinner = (0, spinner_1.createSpinner)("📁 Discovering files...");
|
|
216
|
+
const files = await collectFiles(directory, extensions, exclude);
|
|
217
|
+
fileSpinner.succeed(`✅ Found ${chalk_1.default.cyan(files.length)} files to analyze`);
|
|
218
|
+
// Track which keys are used
|
|
219
|
+
const usedKeys = new Set();
|
|
220
|
+
const dynamicKeys = new Set(); // Keys that are used dynamically
|
|
221
|
+
const keyUsageMap = new Map(); // key -> array of file paths where it's used
|
|
222
|
+
// Progress bar for file processing (primary indicator)
|
|
223
|
+
const fileProgressBar = new progress_1.ProgressBar({
|
|
224
|
+
total: files.length,
|
|
225
|
+
format: `${chalk_1.default.blue("{bar}")} | ${chalk_1.default.yellow("{percentage}%")} | ${chalk_1.default.white("Processing files: {value}/{total}")}`,
|
|
226
|
+
});
|
|
227
|
+
// Cleanup progress bar on cancellation
|
|
228
|
+
cancellationToken.onCancel(() => {
|
|
229
|
+
fileProgressBar.stop();
|
|
230
|
+
console.log(chalk_1.default.yellow("\n\n⚠️ Cancellation requested. Cleaning up...\n"));
|
|
231
|
+
});
|
|
232
|
+
// Track progress
|
|
233
|
+
let processedFiles = 0;
|
|
234
|
+
let totalKeyChecks = 0;
|
|
235
|
+
const keysCheckedPerFile = new Map();
|
|
236
|
+
// Process files in parallel batches with optimized concurrency
|
|
237
|
+
// For file I/O operations, optimal concurrency is lower to avoid I/O contention
|
|
238
|
+
// Too high concurrency can actually slow things down due to disk I/O bottlenecks
|
|
239
|
+
const fileCount = files.length;
|
|
240
|
+
// Optimal concurrency for file I/O: 20-30 is usually the sweet spot
|
|
241
|
+
// Higher concurrency doesn't help much with disk I/O and can cause contention
|
|
242
|
+
const defaultConcurrency = fileCount > 500 ? 30 : fileCount > 100 ? 25 : 20;
|
|
243
|
+
const maxConcurrency = providedConcurrency || defaultConcurrency;
|
|
244
|
+
// Show key checking progress as we process files
|
|
245
|
+
console.log(chalk_1.default.cyan(`\n🔍 Checking ${chalk_1.default.bold(allKeys.length)} translation keys across ${chalk_1.default.bold(files.length)} files...\n` +
|
|
246
|
+
chalk_1.default.gray(` Using ${maxConcurrency} concurrent file readers (optimal for I/O performance)\n`)));
|
|
247
|
+
// Pre-compile regex patterns for all keys to avoid recompiling
|
|
248
|
+
const keyPatterns = new Map();
|
|
249
|
+
const dynamicPatterns = new Map();
|
|
250
|
+
for (const keyData of allKeys) {
|
|
251
|
+
const escapedKey = keyData.path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
252
|
+
const keyParts = keyData.path.split('.');
|
|
253
|
+
// Static patterns
|
|
254
|
+
const staticPatterns = [
|
|
255
|
+
new RegExp(`(?:t|i18n\\.t|\\$t|translate)\\s*\\(\\s*['"\`]${escapedKey}['"\`]`, "g"),
|
|
256
|
+
new RegExp(`\\[\\s*['"\`]${escapedKey}['"\`]\\s*\\]`, "g"),
|
|
257
|
+
new RegExp(`\\.${escapedKey}(?:\\s|;|,|\\)|\\]|\\}|\\n|$)`, "g"),
|
|
258
|
+
new RegExp(`['"\`]${escapedKey}['"\`]`, "g"),
|
|
259
|
+
];
|
|
260
|
+
keyPatterns.set(keyData.path, staticPatterns);
|
|
261
|
+
// Dynamic patterns for parent keys
|
|
262
|
+
const dynPatterns = [];
|
|
263
|
+
for (let i = 0; i < keyParts.length; i++) {
|
|
264
|
+
const partialKey = keyParts.slice(0, i + 1).join('.');
|
|
265
|
+
const escapedPartialKey = partialKey.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
266
|
+
dynPatterns.push(new RegExp("(?:t|i18n\\.t|\\$t|translate)\\s*\\(\\s*`[^`]*" + escapedPartialKey + "[^`]*\\$\\{[^}]+\\}[^`]*`", "g"), new RegExp("(?:t|i18n\\.t|\\$t|translate)\\s*\\(\\s*['\"`]" + escapedPartialKey + "\\.?['\"`]\\s*\\+", "g"), new RegExp("(?:t|i18n\\.t|\\$t|translate)\\s*\\([^)]*\\+\\s*['\"`]\\.?" + escapedPartialKey + "['\"`]", "g"));
|
|
267
|
+
}
|
|
268
|
+
dynamicPatterns.set(keyData.path, dynPatterns);
|
|
269
|
+
}
|
|
270
|
+
// Optimized: Process files in large batches with optimal concurrency
|
|
271
|
+
const processFile = async (filePath) => {
|
|
272
|
+
// Check cancellation before processing
|
|
273
|
+
if (cancellationToken.isCancelled()) {
|
|
274
|
+
throw new Error("Operation cancelled by user");
|
|
275
|
+
}
|
|
276
|
+
try {
|
|
277
|
+
const content = await fs.readFile(filePath, "utf-8");
|
|
278
|
+
// Optimized: Check all unused keys in a single pass
|
|
279
|
+
// Only check keys that haven't been marked as used yet
|
|
280
|
+
const keysToCheck = allKeys.filter((key) => !usedKeys.has(key.path));
|
|
281
|
+
// Check cancellation periodically
|
|
282
|
+
if (cancellationToken.isCancelled()) {
|
|
283
|
+
throw new Error("Operation cancelled by user");
|
|
284
|
+
}
|
|
285
|
+
// Process keys in smaller batches to allow cancellation checks
|
|
286
|
+
const keyBatchSize = 100; // Check 100 keys at a time
|
|
287
|
+
for (let i = 0; i < keysToCheck.length; i += keyBatchSize) {
|
|
288
|
+
if (cancellationToken.isCancelled()) {
|
|
289
|
+
throw new Error("Operation cancelled by user");
|
|
290
|
+
}
|
|
291
|
+
const keyBatch = keysToCheck.slice(i, i + keyBatchSize);
|
|
292
|
+
for (const keyData of keyBatch) {
|
|
293
|
+
totalKeyChecks++;
|
|
294
|
+
// Check static patterns first (faster)
|
|
295
|
+
const staticPats = keyPatterns.get(keyData.path);
|
|
296
|
+
if (staticPats.some((pattern) => pattern.test(content))) {
|
|
297
|
+
usedKeys.add(keyData.path);
|
|
298
|
+
if (!keyUsageMap.has(keyData.path)) {
|
|
299
|
+
keyUsageMap.set(keyData.path, []);
|
|
300
|
+
}
|
|
301
|
+
keyUsageMap.get(keyData.path).push(filePath);
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
// Check dynamic patterns
|
|
305
|
+
const dynPats = dynamicPatterns.get(keyData.path);
|
|
306
|
+
if (dynPats.some((pattern) => pattern.test(content))) {
|
|
307
|
+
usedKeys.add(keyData.path);
|
|
308
|
+
dynamicKeys.add(keyData.path);
|
|
309
|
+
// If this is a parent key used dynamically, mark all children as potentially used
|
|
310
|
+
for (const otherKey of allKeys) {
|
|
311
|
+
if (isParentKey(keyData.path, otherKey.path)) {
|
|
312
|
+
usedKeys.add(otherKey.path);
|
|
313
|
+
dynamicKeys.add(otherKey.path);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
if (!keyUsageMap.has(keyData.path)) {
|
|
317
|
+
keyUsageMap.set(keyData.path, []);
|
|
318
|
+
}
|
|
319
|
+
keyUsageMap.get(keyData.path).push(filePath);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
catch (error) {
|
|
325
|
+
// Re-throw cancellation errors
|
|
326
|
+
if ((error === null || error === void 0 ? void 0 : error.message) === "Operation cancelled by user") {
|
|
327
|
+
throw error;
|
|
328
|
+
}
|
|
329
|
+
// Skip files that can't be read
|
|
330
|
+
// Silently skip to avoid cluttering output
|
|
331
|
+
}
|
|
332
|
+
if (!cancellationToken.isCancelled()) {
|
|
333
|
+
processedFiles++;
|
|
334
|
+
fileProgressBar.update(1);
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
// Process files in parallel batches
|
|
338
|
+
const batches = [];
|
|
339
|
+
for (let i = 0; i < files.length; i += maxConcurrency) {
|
|
340
|
+
batches.push(files.slice(i, i + maxConcurrency));
|
|
341
|
+
}
|
|
342
|
+
// Process all batches with cancellation support
|
|
343
|
+
try {
|
|
344
|
+
for (const batch of batches) {
|
|
345
|
+
// Check cancellation before each batch
|
|
346
|
+
if (cancellationToken.isCancelled()) {
|
|
347
|
+
fileProgressBar.stop();
|
|
348
|
+
throw new Error("Operation cancelled by user");
|
|
349
|
+
}
|
|
350
|
+
// Use Promise.allSettled to handle cancellation better
|
|
351
|
+
const results = await Promise.allSettled(batch.map(async (filePath) => {
|
|
352
|
+
if (cancellationToken.isCancelled()) {
|
|
353
|
+
throw new Error("Operation cancelled by user");
|
|
354
|
+
}
|
|
355
|
+
return processFile(filePath);
|
|
356
|
+
}));
|
|
357
|
+
// Check if any promise was cancelled
|
|
358
|
+
const cancelled = results.some((result) => {
|
|
359
|
+
var _a;
|
|
360
|
+
return result.status === "rejected" &&
|
|
361
|
+
((_a = result.reason) === null || _a === void 0 ? void 0 : _a.message) === "Operation cancelled by user";
|
|
362
|
+
});
|
|
363
|
+
if (cancelled || cancellationToken.isCancelled()) {
|
|
364
|
+
fileProgressBar.stop();
|
|
365
|
+
throw new Error("Operation cancelled by user");
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
catch (error) {
|
|
370
|
+
fileProgressBar.stop();
|
|
371
|
+
if ((error === null || error === void 0 ? void 0 : error.message) === "Operation cancelled by user" || cancellationToken.isCancelled()) {
|
|
372
|
+
throw new Error("Operation cancelled by user");
|
|
373
|
+
}
|
|
374
|
+
throw error;
|
|
375
|
+
}
|
|
376
|
+
cancellationToken.throwIfCancelled();
|
|
377
|
+
fileProgressBar.complete();
|
|
378
|
+
// Show summary with dynamic key information
|
|
379
|
+
if (dynamicKeys.size > 0) {
|
|
380
|
+
console.log(chalk_1.default.yellow(`\n⚠️ Note: Found ${chalk_1.default.bold(dynamicKeys.size)} keys used dynamically (e.g., t(\`a.b.\${var}\`))\n` +
|
|
381
|
+
chalk_1.default.gray(` These keys and their children are marked as used to prevent false positives.\n`)));
|
|
382
|
+
}
|
|
383
|
+
console.log(chalk_1.default.green(`\n✅ Completed: Checked ${chalk_1.default.cyan(allKeys.length)} translation keys across ${chalk_1.default.cyan(processedFiles)} files\n`));
|
|
384
|
+
// Find unused keys
|
|
385
|
+
const unusedKeys = allKeys.filter((key) => !usedKeys.has(key.path));
|
|
386
|
+
return unusedKeys;
|
|
387
|
+
};
|
|
388
|
+
exports.findUnusedKeys = findUnusedKeys;
|
|
389
|
+
/**
|
|
390
|
+
* Removes a key from a nested object using the original path
|
|
391
|
+
*/
|
|
392
|
+
const removeKeyFromObject = (obj, path) => {
|
|
393
|
+
if (path.length === 0)
|
|
394
|
+
return;
|
|
395
|
+
const [currentKey, ...remainingPath] = path;
|
|
396
|
+
if (remainingPath.length === 0) {
|
|
397
|
+
// This is the final key, delete it
|
|
398
|
+
delete obj[currentKey];
|
|
399
|
+
}
|
|
400
|
+
else {
|
|
401
|
+
// Navigate deeper
|
|
402
|
+
if (obj[currentKey] && typeof obj[currentKey] === "object") {
|
|
403
|
+
removeKeyFromObject(obj[currentKey], remainingPath);
|
|
404
|
+
// Clean up empty objects
|
|
405
|
+
if (Object.keys(obj[currentKey]).length === 0) {
|
|
406
|
+
delete obj[currentKey];
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
};
|
|
411
|
+
/**
|
|
412
|
+
* Prompts user for confirmation
|
|
413
|
+
*/
|
|
414
|
+
const askConfirmation = (question) => {
|
|
415
|
+
const rl = readline.createInterface({
|
|
416
|
+
input: process.stdin,
|
|
417
|
+
output: process.stdout,
|
|
418
|
+
});
|
|
419
|
+
return new Promise((resolve) => {
|
|
420
|
+
rl.question(question, (answer) => {
|
|
421
|
+
rl.close();
|
|
422
|
+
resolve(answer.toLowerCase() === "y" || answer.toLowerCase() === "yes");
|
|
423
|
+
});
|
|
424
|
+
});
|
|
425
|
+
};
|
|
426
|
+
/**
|
|
427
|
+
* Analyzes and removes unused i18n keys
|
|
428
|
+
*/
|
|
429
|
+
const analyzeAndRemoveUnusedKeys = async (options) => {
|
|
430
|
+
const { directory, i18nFile } = options;
|
|
431
|
+
const i18nFilePath = path.isAbsolute(i18nFile)
|
|
432
|
+
? i18nFile
|
|
433
|
+
: path.join(directory, i18nFile);
|
|
434
|
+
console.log(chalk_1.default.bold(`\nAnalyzing unused translation keys in ${chalk_1.default.cyan(i18nFilePath)}\n`));
|
|
435
|
+
try {
|
|
436
|
+
// Find unused keys
|
|
437
|
+
const unusedKeys = await (0, exports.findUnusedKeys)(options);
|
|
438
|
+
// Check if cancelled
|
|
439
|
+
if (unusedKeys.length === 0 && !options.maxConcurrency) {
|
|
440
|
+
// This might be a cancellation, but we'll let it through for now
|
|
441
|
+
}
|
|
442
|
+
if (unusedKeys.length === 0) {
|
|
443
|
+
console.log(chalk_1.default.green("\n✓ No unused translation keys found!\n"));
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
// Display unused keys
|
|
447
|
+
console.log(chalk_1.default.yellow(`\nFound ${chalk_1.default.bold(unusedKeys.length)} unused translation key(s):\n`));
|
|
448
|
+
unusedKeys.forEach((key, index) => {
|
|
449
|
+
console.log(chalk_1.default.gray(`${index + 1}. `) + chalk_1.default.white(key.path) + chalk_1.default.gray(` (${JSON.stringify(key.value)})`));
|
|
450
|
+
});
|
|
451
|
+
// Ask for confirmation
|
|
452
|
+
const confirmed = await askConfirmation(chalk_1.default.yellow(`\nDo you want to remove these ${unusedKeys.length} unused key(s)? (y/n): `));
|
|
453
|
+
if (!confirmed) {
|
|
454
|
+
console.log(chalk_1.default.gray("\nOperation cancelled.\n"));
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
// Read the original file
|
|
458
|
+
const fileContent = await fs.readFile(i18nFilePath, "utf-8");
|
|
459
|
+
const i18nContent = JSON.parse(fileContent);
|
|
460
|
+
// Remove unused keys
|
|
461
|
+
for (const key of unusedKeys) {
|
|
462
|
+
removeKeyFromObject(i18nContent, key.originalPath);
|
|
463
|
+
}
|
|
464
|
+
// Write back to file
|
|
465
|
+
await fs.writeFile(i18nFilePath, JSON.stringify(i18nContent, null, 2) + "\n", "utf-8");
|
|
466
|
+
console.log(chalk_1.default.green(`\n✓ Successfully removed ${unusedKeys.length} unused translation key(s) from ${i18nFilePath}\n`));
|
|
467
|
+
}
|
|
468
|
+
catch (error) {
|
|
469
|
+
if ((error === null || error === void 0 ? void 0 : error.message) === "Operation cancelled by user") {
|
|
470
|
+
console.log(chalk_1.default.yellow("\n\n✅ Analysis cancelled by user\n"));
|
|
471
|
+
process.exit(130); // Standard exit code for SIGINT
|
|
472
|
+
}
|
|
473
|
+
console.error(chalk_1.default.red(`\nError: ${error}\n`));
|
|
474
|
+
process.exit(1);
|
|
475
|
+
}
|
|
476
|
+
};
|
|
477
|
+
exports.analyzeAndRemoveUnusedKeys = analyzeAndRemoveUnusedKeys;
|