i18ntk 4.3.2 → 4.4.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/CHANGELOG.md +148 -87
- package/README.md +63 -48
- package/main/i18ntk-backup.js +77 -52
- package/main/i18ntk-complete.js +16 -5
- package/main/i18ntk-scanner.js +5 -0
- package/main/i18ntk-translate.js +11 -3
- package/main/i18ntk-usage.js +428 -113
- package/main/i18ntk-validate.js +9 -8
- package/main/manage/commands/TranslateCommand.js +2 -2
- package/main/manage/services/UsageService.js +24 -20
- package/package.json +32 -17
- package/utils/config-helper.js +19 -3
- package/utils/english-placeholder-checker.js +15 -2
- package/utils/extractors/regex.js +19 -3
- package/utils/framework-detector.js +9 -9
- package/utils/security.js +49 -6
- package/utils/translate/api.js +16 -1
- package/utils/translate/report.js +26 -2
- package/utils/usage-insights.js +254 -3
package/main/i18ntk-usage.js
CHANGED
|
@@ -100,6 +100,14 @@ class I18nUsageAnalyzer {
|
|
|
100
100
|
this.cleanupMode = false;
|
|
101
101
|
this.dryRunDelete = false;
|
|
102
102
|
this._sourceCommentsSet = null;
|
|
103
|
+
this._dynamicallyReferencedKeys = new Set();
|
|
104
|
+
this._dynamicPrefixes = new Set();
|
|
105
|
+
this._resolvedDynamicExpansions = new Map();
|
|
106
|
+
this._clientBoundaryIssues = [];
|
|
107
|
+
this._mojibakeIssues = [];
|
|
108
|
+
this._copyFormatters = [];
|
|
109
|
+
this._telemetryLiterals = [];
|
|
110
|
+
this._localWrapperRefs = [];
|
|
103
111
|
|
|
104
112
|
// Use global translation function
|
|
105
113
|
this.rl = null;
|
|
@@ -161,20 +169,24 @@ class I18nUsageAnalyzer {
|
|
|
161
169
|
this.sourceLanguageDir = path.join(this.i18nDir, this.config.sourceLanguage);
|
|
162
170
|
}
|
|
163
171
|
|
|
164
|
-
|
|
165
|
-
|
|
172
|
+
// Path display deferred until after CLI arg overrides are applied in run()
|
|
166
173
|
|
|
167
174
|
// Ensure translation patterns are defined
|
|
168
|
-
this.config = this.config || {};
|
|
175
|
+
this.config = this.config || {};
|
|
169
176
|
this.config.translationPatterns = this.config.translationPatterns || [
|
|
170
|
-
/t\(['"`]([^'"`]+)['"`]/g,
|
|
177
|
+
/(?<![\w$.])t\s*\(['"`]([^'"`]+)['"`]/g,
|
|
178
|
+
/(?<![\w$.])tx\s*\(['"`]([^'"`]+)['"`]/g,
|
|
179
|
+
/\.tx\s*\(['"`]([^'"`]+)['"`]/g,
|
|
180
|
+
/(?<=[)\s])\.tx\s*\(['"`]([^'"`]+)['"`]/g,
|
|
171
181
|
/i18n\.t\(['"`]([^'"`]+)['"`]/g,
|
|
172
182
|
/useTranslation\(\)\.t\(['"`]([^'"`]+)['"`]/g,
|
|
173
|
-
/t\(`([^`]+)`\)/g,
|
|
183
|
+
/(?<![\w$.])t\s*\(`([^`]+)`\)/g,
|
|
184
|
+
/(?<![\w$.])tx\s*\(`([^`]+)`\)/g,
|
|
185
|
+
/\.tx\s*\(`([^`]+)`\)/g,
|
|
174
186
|
/i18nKey=['"`]([^'"`]+)['"`]/g,
|
|
175
187
|
/\$t\(['"`]([^'"`]+)['"`]/g,
|
|
176
|
-
/getTranslation\(['"`]([^'"`]+)['"`]/g
|
|
177
|
-
];
|
|
188
|
+
/(?<![\w$.])getTranslation\s*\(['"`]([^'"`]+)['"`]/g
|
|
189
|
+
];
|
|
178
190
|
this.extractor = getExtractor(this.config.extractor);
|
|
179
191
|
|
|
180
192
|
// Ensure defaults for other config values
|
|
@@ -206,7 +218,11 @@ class I18nUsageAnalyzer {
|
|
|
206
218
|
strict: a.strict,
|
|
207
219
|
debug: a.debug,
|
|
208
220
|
cleanup: a.cleanup ?? a['cleanup'],
|
|
209
|
-
dryRunDelete: a.dryRunDelete ?? a['dry-run-delete']
|
|
221
|
+
dryRunDelete: a.dryRunDelete ?? a['dry-run-delete'],
|
|
222
|
+
strictUnused: a.strictUnused ?? a['strict-unused'],
|
|
223
|
+
json: a.json,
|
|
224
|
+
prune: a.prune,
|
|
225
|
+
pruneKeep: parseInt(a['prune-keep'] || a.pruneKeep, 10) || 10
|
|
210
226
|
};
|
|
211
227
|
}
|
|
212
228
|
|
|
@@ -401,6 +417,12 @@ class I18nUsageAnalyzer {
|
|
|
401
417
|
if (toBool(args.dryRunDelete)) {
|
|
402
418
|
this.dryRunDelete = true;
|
|
403
419
|
}
|
|
420
|
+
|
|
421
|
+
if (toBool(args.prune)) {
|
|
422
|
+
const outputDir = args.outputDir || this.config.outputDir || './i18ntk-reports/usage';
|
|
423
|
+
this.pruneReports(outputDir, args.pruneKeep || 10);
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
404
426
|
|
|
405
427
|
try {
|
|
406
428
|
// Ensure config is always initialized
|
|
@@ -422,15 +444,19 @@ class I18nUsageAnalyzer {
|
|
|
422
444
|
loadTranslations(uiLanguage, path.resolve(__dirname, '..', 'ui-locales'));
|
|
423
445
|
if (!Array.isArray(this.config.translationPatterns)) {
|
|
424
446
|
this.config.translationPatterns = [
|
|
425
|
-
/t\(['"`]([^'"`]+)['"`]/g,
|
|
447
|
+
/(?<![\w$.])t\s*\(['"`]([^'"`]+)['"`]/g,
|
|
448
|
+
/(?<![\w$.])tx\s*\(['"`]([^'"`]+)['"`]/g,
|
|
449
|
+
/\.tx\s*\(['"`]([^'"`]+)['"`]/g,
|
|
426
450
|
/i18n\.t\(['"`]([^'"`]+)['"`]/g,
|
|
427
451
|
/useTranslation\(\)\.t\(['"`]([^'"`]+)['"`]/g,
|
|
428
|
-
/t\(`([^`]+)`\)/g,
|
|
452
|
+
/(?<![\w$.])t\s*\(`([^`]+)`\)/g,
|
|
453
|
+
/(?<![\w$.])tx\s*\(`([^`]+)`\)/g,
|
|
454
|
+
/\.tx\s*\(`([^`]+)`\)/g,
|
|
429
455
|
/i18nKey=['"`]([^'"`]+)['"`]/g,
|
|
430
456
|
/\$t\(['"`]([^'"`]+)['"`]/g,
|
|
431
|
-
/getTranslation\(['"`]([^'"`]+)['"`]/g
|
|
457
|
+
/(?<![\w$.])getTranslation\s*\(['"`]([^'"`]+)['"`]/g
|
|
432
458
|
];
|
|
433
|
-
}
|
|
459
|
+
}
|
|
434
460
|
if (!Array.isArray(this.config.excludeDirs)) {
|
|
435
461
|
this.config.excludeDirs = ['node_modules', '.git'];
|
|
436
462
|
}
|
|
@@ -534,6 +560,8 @@ class I18nUsageAnalyzer {
|
|
|
534
560
|
});
|
|
535
561
|
}
|
|
536
562
|
|
|
563
|
+
displayPaths({ sourceDir: this.sourceDir, i18nDir: this.i18nDir, outputDir: this.config.outputDir });
|
|
564
|
+
|
|
537
565
|
console.log(t('usage.detectedSourceDirectory', { sourceDir: this.sourceDir || t('usage.noSourceDirectoryConfigured') || '(none)' }));
|
|
538
566
|
console.log(t('usage.detectedI18nDirectory', { i18nDir: this.i18nDir }));
|
|
539
567
|
|
|
@@ -646,8 +674,50 @@ class I18nUsageAnalyzer {
|
|
|
646
674
|
}
|
|
647
675
|
|
|
648
676
|
if (args.outputReport) {
|
|
649
|
-
const
|
|
650
|
-
|
|
677
|
+
const confidenceFilteredUnused = this._computeConfidenceFilteredUnused(unusedKeys);
|
|
678
|
+
const report = this.generateUsageReport(confidenceFilteredUnused);
|
|
679
|
+
if (args.json) {
|
|
680
|
+
await this.saveJsonReport(report, args.outputDir);
|
|
681
|
+
} else {
|
|
682
|
+
await this.saveReport(report, args.outputDir);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
if (args.json && !args.outputReport) {
|
|
687
|
+
const confidenceFilteredUnused = this._computeConfidenceFilteredUnused(unusedKeys);
|
|
688
|
+
const jsonReport = this.generateJsonReport(confidenceFilteredUnused);
|
|
689
|
+
console.log(JSON.stringify(jsonReport, null, 2));
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
if (this._mojibakeIssues.length > 0) {
|
|
693
|
+
console.log('\n🔤 Locale Quality - Mojibake Artifacts:');
|
|
694
|
+
this._mojibakeIssues.forEach(issue => {
|
|
695
|
+
console.log(` ⚠️ ${issue.key} [${issue.locale}]: ${issue.artifact}`);
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
if (this._clientBoundaryIssues.length > 0) {
|
|
700
|
+
console.log('\n📦 Client-Boundary Warnings:');
|
|
701
|
+
this._clientBoundaryIssues.forEach(issue => {
|
|
702
|
+
console.log(` ⚠️ ${issue.filePath}: ${issue.message}`);
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
if (this._copyFormatters.length > 0) {
|
|
707
|
+
const suspected = this._copyFormatters.filter(cf => cf.type === 'suspectedCopyFormatter');
|
|
708
|
+
if (suspected.length > 0) {
|
|
709
|
+
console.log('\n🔧 Suspected Copy Formatters:');
|
|
710
|
+
suspected.forEach(cf => {
|
|
711
|
+
console.log(` ⚠️ ${cf.filePath}:${cf.line} - ${cf.message}`);
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
if (toBool(args.strictUnused)) {
|
|
717
|
+
const deadKeys = this.findDeadKeys();
|
|
718
|
+
const strictUnused = deadKeys.filter(dk => dk.confidence >= 0.8);
|
|
719
|
+
const strictCount = strictUnused.length;
|
|
720
|
+
console.log(`\n🔒 Strict-Unused Mode: ${strictCount} high-confidence dead keys (filtered from ${deadKeys.length} total candidates)`);
|
|
651
721
|
}
|
|
652
722
|
|
|
653
723
|
console.log('\n' + t('usage.analysisCompletedSuccessfully'));
|
|
@@ -683,6 +753,8 @@ Options:
|
|
|
683
753
|
--output-report Generate detailed usage report
|
|
684
754
|
--output-dir=<path> Directory for output reports (default: ./i18ntk-reports/usage)
|
|
685
755
|
--strict Show all warnings and errors during analysis
|
|
756
|
+
--strict-unused Only report high-confidence (>80%) unused keys
|
|
757
|
+
--json Output results as JSON (to console or with --output-report)
|
|
686
758
|
--debug Enable debug mode with stack traces
|
|
687
759
|
--no-prompt Skip interactive prompts (useful for CI/CD)
|
|
688
760
|
--validate-placeholders Enable placeholder key validation
|
|
@@ -699,7 +771,7 @@ Examples:
|
|
|
699
771
|
node i18ntk-usage.js --cleanup --dry-run-delete
|
|
700
772
|
|
|
701
773
|
Analysis Features (v1.10.1):
|
|
702
|
-
• Detects unused translation keys
|
|
774
|
+
• Detects unused translation keys with confidence scoring
|
|
703
775
|
• Identifies missing translation keys
|
|
704
776
|
• Shows translation completeness by language
|
|
705
777
|
• Reports NOT_TRANSLATED values
|
|
@@ -708,13 +780,19 @@ Analysis Features (v1.10.1):
|
|
|
708
780
|
• Framework-specific pattern recognition (React, Vue, Angular)
|
|
709
781
|
• Advanced translation completeness scoring
|
|
710
782
|
• Performance metrics and optimization tracking
|
|
711
|
-
• Key complexity analysis
|
|
712
|
-
• Known-key literal matching with source file locations
|
|
713
|
-
• Namespace/file naming recommendations (for example app/shop -> shop.json)
|
|
714
|
-
• Hardcoded user-facing text candidates with suggested translation keys
|
|
783
|
+
• Key complexity analysis
|
|
784
|
+
• Known-key literal matching with source file locations
|
|
785
|
+
• Namespace/file naming recommendations (for example app/shop -> shop.json)
|
|
786
|
+
• Hardcoded user-facing text candidates with suggested translation keys
|
|
715
787
|
• Security-enhanced path validation
|
|
716
788
|
• Detailed reporting with validation errors
|
|
717
789
|
• Dead key detection with confidence scoring
|
|
790
|
+
• Locale JSON import key detection
|
|
791
|
+
• Client-boundary warnings ("use client" files importing locale JSON)
|
|
792
|
+
• Copy formatter detection (local "tx" that doesn't call translation runtime)
|
|
793
|
+
• Mojibake artifact detection in translations
|
|
794
|
+
• Confidence-split unused key reporting (confirmed / likely / possibly used)
|
|
795
|
+
• JSON output format (--json)
|
|
718
796
|
`);
|
|
719
797
|
}
|
|
720
798
|
|
|
@@ -845,6 +923,17 @@ Analysis Features (v1.10.1):
|
|
|
845
923
|
return keys;
|
|
846
924
|
}
|
|
847
925
|
|
|
926
|
+
_getNestedValue(obj, key) {
|
|
927
|
+
if (!obj || typeof obj !== 'object') return undefined;
|
|
928
|
+
const parts = key.split('.');
|
|
929
|
+
let current = obj;
|
|
930
|
+
for (const part of parts) {
|
|
931
|
+
if (current == null || typeof current !== 'object') return undefined;
|
|
932
|
+
current = current[part];
|
|
933
|
+
}
|
|
934
|
+
return typeof current === 'string' ? current : undefined;
|
|
935
|
+
}
|
|
936
|
+
|
|
848
937
|
collectPlaceholderKeys(obj, prefix = '', language) {
|
|
849
938
|
const patterns = this.placeholderStyles[language] || [];
|
|
850
939
|
const regexes = patterns.reduce((compiled, pattern) => {
|
|
@@ -895,25 +984,54 @@ Analysis Features (v1.10.1):
|
|
|
895
984
|
return this.extractor.extract(content, rawPatterns);
|
|
896
985
|
}
|
|
897
986
|
|
|
898
|
-
recordUsageInsights(relativePath, insights) {
|
|
899
|
-
const keys = [];
|
|
900
|
-
const seen = new Set();
|
|
901
|
-
|
|
902
|
-
for (const ref of insights.keyReferences || []) {
|
|
903
|
-
if (!ref.key || seen.has(ref.key)) continue;
|
|
904
|
-
seen.add(ref.key);
|
|
905
|
-
keys.push(ref.key);
|
|
906
|
-
this.usedKeys.add(ref.key);
|
|
907
|
-
|
|
908
|
-
if (
|
|
909
|
-
this.
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
987
|
+
recordUsageInsights(relativePath, insights) {
|
|
988
|
+
const keys = [];
|
|
989
|
+
const seen = new Set();
|
|
990
|
+
|
|
991
|
+
for (const ref of insights.keyReferences || []) {
|
|
992
|
+
if (!ref.key || seen.has(ref.key)) continue;
|
|
993
|
+
seen.add(ref.key);
|
|
994
|
+
keys.push(ref.key);
|
|
995
|
+
this.usedKeys.add(ref.key);
|
|
996
|
+
|
|
997
|
+
if (ref.matchType === 'dynamic-template' || ref.matchType === 'dynamic-variable') {
|
|
998
|
+
this._dynamicallyReferencedKeys.add(ref.key);
|
|
999
|
+
if (!this._resolvedDynamicExpansions.has(ref.key)) {
|
|
1000
|
+
this._resolvedDynamicExpansions.set(ref.key, []);
|
|
1001
|
+
}
|
|
1002
|
+
this._resolvedDynamicExpansions.get(ref.key).push(relativePath);
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
if (ref.matchType === 'literal-telemetry') {
|
|
1006
|
+
if (!this._telemetryLiterals) this._telemetryLiterals = [];
|
|
1007
|
+
this._telemetryLiterals.push({
|
|
1008
|
+
filePath: relativePath,
|
|
1009
|
+
key: ref.key,
|
|
1010
|
+
line: ref.line,
|
|
1011
|
+
column: ref.column,
|
|
1012
|
+
contextNote: ref.context?.contextNote,
|
|
1013
|
+
});
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
if (ref.matchType === 'local-wrapper') {
|
|
1017
|
+
if (!this._localWrapperRefs) this._localWrapperRefs = [];
|
|
1018
|
+
this._localWrapperRefs.push({
|
|
1019
|
+
filePath: relativePath,
|
|
1020
|
+
key: ref.key,
|
|
1021
|
+
line: ref.line,
|
|
1022
|
+
wrapperName: ref.wrapperName,
|
|
1023
|
+
});
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
if (!this.keyUsageLocations.has(ref.key)) {
|
|
1027
|
+
this.keyUsageLocations.set(ref.key, []);
|
|
1028
|
+
}
|
|
1029
|
+
this.keyUsageLocations.get(ref.key).push({
|
|
1030
|
+
filePath: relativePath,
|
|
1031
|
+
line: ref.line,
|
|
1032
|
+
column: ref.column,
|
|
1033
|
+
matchType: ref.matchType,
|
|
1034
|
+
});
|
|
917
1035
|
}
|
|
918
1036
|
|
|
919
1037
|
if (keys.length > 0) {
|
|
@@ -931,13 +1049,31 @@ Analysis Features (v1.10.1):
|
|
|
931
1049
|
this.hardcodedTextCandidates.push(...insights.hardcodedTexts);
|
|
932
1050
|
}
|
|
933
1051
|
|
|
934
|
-
if (Array.isArray(insights.unresolvedDynamicReferences) && insights.unresolvedDynamicReferences.length > 0) {
|
|
935
|
-
this.unresolvedDynamicReferences.push(...insights.unresolvedDynamicReferences.map(ref => ({
|
|
936
|
-
filePath: relativePath,
|
|
937
|
-
...ref,
|
|
938
|
-
})));
|
|
939
|
-
|
|
940
|
-
|
|
1052
|
+
if (Array.isArray(insights.unresolvedDynamicReferences) && insights.unresolvedDynamicReferences.length > 0) {
|
|
1053
|
+
this.unresolvedDynamicReferences.push(...insights.unresolvedDynamicReferences.map(ref => ({
|
|
1054
|
+
filePath: relativePath,
|
|
1055
|
+
...ref,
|
|
1056
|
+
})));
|
|
1057
|
+
for (const ref of insights.unresolvedDynamicReferences) {
|
|
1058
|
+
if (ref.prefix) {
|
|
1059
|
+
this._dynamicPrefixes.add(ref.prefix);
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
if (Array.isArray(insights.clientBoundaryIssues) && insights.clientBoundaryIssues.length > 0) {
|
|
1065
|
+
if (!this._clientBoundaryIssues) this._clientBoundaryIssues = [];
|
|
1066
|
+
this._clientBoundaryIssues.push(...insights.clientBoundaryIssues);
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
if (Array.isArray(insights.copyFormatters) && insights.copyFormatters.length > 0) {
|
|
1070
|
+
if (!this._copyFormatters) this._copyFormatters = [];
|
|
1071
|
+
this._copyFormatters.push(...insights.copyFormatters.map(cf => ({
|
|
1072
|
+
filePath: relativePath,
|
|
1073
|
+
...cf,
|
|
1074
|
+
})));
|
|
1075
|
+
}
|
|
1076
|
+
|
|
941
1077
|
return keys.length;
|
|
942
1078
|
}
|
|
943
1079
|
|
|
@@ -1135,6 +1271,19 @@ Analysis Features (v1.10.1):
|
|
|
1135
1271
|
const stats = this.analyzeFileCompleteness(jsonData);
|
|
1136
1272
|
totalKeys += stats.total;
|
|
1137
1273
|
translatedKeys += stats.translated;
|
|
1274
|
+
|
|
1275
|
+
if (language !== this.config.sourceLanguage) {
|
|
1276
|
+
const { detectMojibakeInTranslations } = require('../utils/usage-insights');
|
|
1277
|
+
const flatKeys = this.extractKeysFromObject(jsonData, '');
|
|
1278
|
+
for (const key of flatKeys) {
|
|
1279
|
+
const value = this._getNestedValue(jsonData, key);
|
|
1280
|
+
const issue = detectMojibakeInTranslations(key, value, this.config.sourceLanguage, language);
|
|
1281
|
+
if (issue) {
|
|
1282
|
+
issue.filePath = fileInfo.filePath;
|
|
1283
|
+
this._mojibakeIssues.push(issue);
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1138
1287
|
} catch (error) {
|
|
1139
1288
|
if (isDebug || isStrict) {
|
|
1140
1289
|
console.warn(`❌ Failed to analyze file ${path.basename(fileInfo.filePath)}: ${error.message}`);
|
|
@@ -1269,9 +1418,12 @@ Analysis Features (v1.10.1):
|
|
|
1269
1418
|
let confidence = 0.9;
|
|
1270
1419
|
let reason = 'Key not found in any source file';
|
|
1271
1420
|
|
|
1272
|
-
if (this.
|
|
1421
|
+
if (this._resolvedDynamicExpansions.has(key)) {
|
|
1422
|
+
confidence = 0.2;
|
|
1423
|
+
reason = `Key resolved through dynamic template expansion (${this._resolvedDynamicExpansions.get(key).join(', ')})`;
|
|
1424
|
+
} else if (this._matchesDynamicPrefix(key)) {
|
|
1273
1425
|
confidence = 0.3;
|
|
1274
|
-
reason = 'Key matches dynamic template pattern
|
|
1426
|
+
reason = 'Key prefix matches unresolved dynamic template pattern';
|
|
1275
1427
|
} else if (this._keyInSourceComments(key)) {
|
|
1276
1428
|
confidence = 0.5;
|
|
1277
1429
|
reason = 'Key referenced in comments/JSDoc';
|
|
@@ -1287,39 +1439,19 @@ Analysis Features (v1.10.1):
|
|
|
1287
1439
|
return deadKeys;
|
|
1288
1440
|
}
|
|
1289
1441
|
|
|
1290
|
-
|
|
1291
|
-
const
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
/i18n\.t\(`[^`]*\$\{[^}]*\}[^`]*`\)/g,
|
|
1297
|
-
/useTranslation\(\)\.t\(`[^`]*\$\{[^}]*\}[^`]*`\)/g
|
|
1298
|
-
];
|
|
1299
|
-
|
|
1300
|
-
try {
|
|
1301
|
-
const sourceFiles = Array.from(this.fileUsage.keys());
|
|
1302
|
-
for (const filePath of sourceFiles) {
|
|
1303
|
-
const fullPath = path.join(this.sourceDir, filePath);
|
|
1304
|
-
if (!SecurityUtils.safeExistsSync(fullPath, this.sourceDir)) continue;
|
|
1305
|
-
|
|
1306
|
-
const content = SecurityUtils.safeReadFileSync(fullPath, this.sourceDir, 'utf8');
|
|
1307
|
-
if (!content) continue;
|
|
1442
|
+
_matchesDynamicPrefix(key) {
|
|
1443
|
+
for (const prefix of this._dynamicPrefixes) {
|
|
1444
|
+
if (key.startsWith(prefix + '.') || key.startsWith(prefix + '_') || key === prefix) {
|
|
1445
|
+
return true;
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1308
1448
|
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
if (keyParts.some(part => matchLower.includes(part.toLowerCase()))) {
|
|
1315
|
-
return true;
|
|
1316
|
-
}
|
|
1317
|
-
}
|
|
1318
|
-
}
|
|
1319
|
-
}
|
|
1449
|
+
for (const resolvedKey of this._dynamicallyReferencedKeys) {
|
|
1450
|
+
const parts = resolvedKey.split('.');
|
|
1451
|
+
for (let i = 1; i < parts.length; i++) {
|
|
1452
|
+
const subPrefix = parts.slice(0, i).join('.');
|
|
1453
|
+
if (key.startsWith(subPrefix + '.')) return true;
|
|
1320
1454
|
}
|
|
1321
|
-
} catch (e) {
|
|
1322
|
-
// Silently fail - dynamic pattern detection is best-effort
|
|
1323
1455
|
}
|
|
1324
1456
|
|
|
1325
1457
|
return false;
|
|
@@ -1562,8 +1694,10 @@ Analysis Features (v1.10.1):
|
|
|
1562
1694
|
report += `${'='.repeat(50)}\n`;
|
|
1563
1695
|
report += `Direct i18n calls: ${matchCounts.direct || 0}\n`;
|
|
1564
1696
|
report += `Known-key literal matches: ${matchCounts.literal || 0}\n`;
|
|
1565
|
-
report += `Resolved dynamic expressions: ${(matchCounts['dynamic-template'] || 0) + (matchCounts['dynamic-variable'] || 0)}\n`;
|
|
1566
|
-
report += `
|
|
1697
|
+
report += `Resolved dynamic expressions: ${(matchCounts['dynamic-template'] || 0) + (matchCounts['dynamic-variable'] || 0)}\n`;
|
|
1698
|
+
report += `Local wrapper references: ${(matchCounts['local-wrapper'] || 0)}\n`;
|
|
1699
|
+
report += `Telemetry/event literals (excluded from usage): ${(this._telemetryLiterals?.length || 0)}\n`;
|
|
1700
|
+
report += `Unresolved dynamic expressions: ${this.unresolvedDynamicReferences.length}\n`;
|
|
1567
1701
|
report += `Indexed keys with file locations: ${this.keyUsageLocations.size}\n\n`;
|
|
1568
1702
|
|
|
1569
1703
|
const indexedKeys = Array.from(this.keyUsageLocations.entries()).slice(0, 100);
|
|
@@ -1594,18 +1728,35 @@ Analysis Features (v1.10.1):
|
|
|
1594
1728
|
report += `\n`;
|
|
1595
1729
|
}
|
|
1596
1730
|
|
|
1597
|
-
if (this.unresolvedDynamicReferences.length > 0) {
|
|
1598
|
-
report += `🧩 Unresolved Dynamic Key Expressions\n`;
|
|
1599
|
-
report += `${'='.repeat(50)}\n`;
|
|
1600
|
-
report += `These calls could not be resolved to exact keys without executing code. Review them manually or prefer bounded literal maps/arrays for analyzable dynamic keys.\n\n`;
|
|
1601
|
-
this.unresolvedDynamicReferences.slice(0, 50).forEach(item => {
|
|
1602
|
-
const prefix = item.prefix ? ` prefix: ${item.prefix}` : ' no static prefix';
|
|
1603
|
-
report += `- ${item.filePath}:${item.line} ${item.expression} (${prefix})\n`;
|
|
1604
|
-
});
|
|
1605
|
-
if (this.unresolvedDynamicReferences.length > 50) {
|
|
1606
|
-
report += `... ${this.unresolvedDynamicReferences.length - 50} more unresolved expressions\n`;
|
|
1607
|
-
}
|
|
1608
|
-
report += `\n`;
|
|
1731
|
+
if (this.unresolvedDynamicReferences.length > 0) {
|
|
1732
|
+
report += `🧩 Unresolved Dynamic Key Expressions\n`;
|
|
1733
|
+
report += `${'='.repeat(50)}\n`;
|
|
1734
|
+
report += `These calls could not be resolved to exact keys without executing code. Review them manually or prefer bounded literal maps/arrays for analyzable dynamic keys.\n\n`;
|
|
1735
|
+
this.unresolvedDynamicReferences.slice(0, 50).forEach(item => {
|
|
1736
|
+
const prefix = item.prefix ? ` prefix: ${item.prefix}` : ' no static prefix';
|
|
1737
|
+
report += `- ${item.filePath}:${item.line} ${item.expression} (${prefix})\n`;
|
|
1738
|
+
});
|
|
1739
|
+
if (this.unresolvedDynamicReferences.length > 50) {
|
|
1740
|
+
report += `... ${this.unresolvedDynamicReferences.length - 50} more unresolved expressions\n`;
|
|
1741
|
+
}
|
|
1742
|
+
report += `\n`;
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
if (this._dynamicallyReferencedKeys.size > 0) {
|
|
1746
|
+
report += `🧩 Resolved Dynamic Key Expansions\n`;
|
|
1747
|
+
report += `${'='.repeat(50)}\n`;
|
|
1748
|
+
report += `These keys were resolved through dynamic template expansion. Consider converting to explicit literal maps for better static analysis.\n\n`;
|
|
1749
|
+
report += `Recommendation pattern:\n`;
|
|
1750
|
+
report += ` const labels = {\n`;
|
|
1751
|
+
report += ` active: tx("namespace.status.active"),\n`;
|
|
1752
|
+
report += ` closed: tx("namespace.status.closed"),\n`;
|
|
1753
|
+
report += ` };\n\n`;
|
|
1754
|
+
const sampleKeys = Array.from(this._dynamicallyReferencedKeys).slice(0, 20);
|
|
1755
|
+
report += `Resolved keys:` + sampleKeys.map(k => `\n - ${k}`).join('') + `\n`;
|
|
1756
|
+
if (this._dynamicallyReferencedKeys.size > 20) {
|
|
1757
|
+
report += ` ... ${this._dynamicallyReferencedKeys.size - 20} more resolved keys\n`;
|
|
1758
|
+
}
|
|
1759
|
+
report += `\n`;
|
|
1609
1760
|
}
|
|
1610
1761
|
|
|
1611
1762
|
if (this.hardcodedTextCandidates.length > 0) {
|
|
@@ -1622,24 +1773,45 @@ Analysis Features (v1.10.1):
|
|
|
1622
1773
|
report += `\n`;
|
|
1623
1774
|
}
|
|
1624
1775
|
|
|
1625
|
-
// Unused keys with complexity
|
|
1776
|
+
// Unused keys with complexity and confidence
|
|
1626
1777
|
if (unusedKeys.length > 0) {
|
|
1778
|
+
const confidenceFiltered = this._computeConfidenceFilteredUnused(unusedKeys);
|
|
1779
|
+
const confirmed = confidenceFiltered.filter(u => u.confidence >= 0.8);
|
|
1780
|
+
const likely = confidenceFiltered.filter(u => u.confidence >= 0.4 && u.confidence < 0.8);
|
|
1781
|
+
const possible = confidenceFiltered.filter(u => u.confidence < 0.4);
|
|
1782
|
+
|
|
1627
1783
|
report += `${t('summary.usageReportUnusedTranslationKeys')}\n`;
|
|
1628
1784
|
report += `${'='.repeat(50)}\n`;
|
|
1629
|
-
report += `${t('summary.usageReportUnusedKeysDescription')}\n
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
report +=
|
|
1785
|
+
report += `${t('summary.usageReportUnusedKeysDescription')}\n`;
|
|
1786
|
+
report += `Confidence breakdown: ${confirmed.length} confirmed, ${likely.length} likely, ${possible.length} possibly used\n\n`;
|
|
1787
|
+
|
|
1788
|
+
if (confirmed.length > 0) {
|
|
1789
|
+
report += `Confirmed Unused (high confidence):\n`;
|
|
1790
|
+
confirmed.slice(0, 50).forEach(u => {
|
|
1791
|
+
report += ` - ${u.key} [${(u.confidence * 100).toFixed(0)}%]\n`;
|
|
1792
|
+
});
|
|
1793
|
+
if (confirmed.length > 50) report += ` ... ${confirmed.length - 50} more\n`;
|
|
1794
|
+
report += `\n`;
|
|
1639
1795
|
}
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1796
|
+
|
|
1797
|
+
if (likely.length > 0) {
|
|
1798
|
+
report += `Likely Unused (medium confidence):\n`;
|
|
1799
|
+
likely.slice(0, 30).forEach(u => {
|
|
1800
|
+
report += ` - ${u.key} [${(u.confidence * 100).toFixed(0)}%] ${u.reason}\n`;
|
|
1801
|
+
});
|
|
1802
|
+
if (likely.length > 30) report += ` ... ${likely.length - 30} more\n`;
|
|
1803
|
+
report += `\n`;
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
if (possible.length > 0) {
|
|
1807
|
+
report += `Possibly Used (low confidence - may be dynamic):\n`;
|
|
1808
|
+
possible.slice(0, 20).forEach(u => {
|
|
1809
|
+
report += ` - ${u.key} [${(u.confidence * 100).toFixed(0)}%] ${u.reason}\n`;
|
|
1810
|
+
});
|
|
1811
|
+
if (possible.length > 20) report += ` ... ${possible.length - 20} more\n`;
|
|
1812
|
+
report += `\n`;
|
|
1813
|
+
}
|
|
1814
|
+
}
|
|
1643
1815
|
|
|
1644
1816
|
// Missing keys with location and framework
|
|
1645
1817
|
if (missingKeys.length > 0) {
|
|
@@ -1733,6 +1905,39 @@ Analysis Features (v1.10.1):
|
|
|
1733
1905
|
if (this.fileUsage.size > 20) {
|
|
1734
1906
|
report += `${t('summary.usageReportMoreFiles', { count: this.fileUsage.size - 20 })}\n`;
|
|
1735
1907
|
}
|
|
1908
|
+
|
|
1909
|
+
if (this._mojibakeIssues && this._mojibakeIssues.length > 0) {
|
|
1910
|
+
report += `\n🔤 Locale Quality - Mojibake Artifacts\n`;
|
|
1911
|
+
report += `${'='.repeat(50)}\n`;
|
|
1912
|
+
report += `Replacement-character artifacts detected in translations. These may indicate encoding issues during translation.\n\n`;
|
|
1913
|
+
this._mojibakeIssues.forEach(issue => {
|
|
1914
|
+
report += ` - ${issue.key} [${issue.locale}]: ${issue.artifact}\n`;
|
|
1915
|
+
});
|
|
1916
|
+
report += `\n`;
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
if (this._clientBoundaryIssues && this._clientBoundaryIssues.length > 0) {
|
|
1920
|
+
report += `\n📦 Client-Boundary Warnings\n`;
|
|
1921
|
+
report += `${'='.repeat(50)}\n`;
|
|
1922
|
+
report += `"use client" files importing locale JSON. This bypasses the shared runtime and increases bundle size.\n\n`;
|
|
1923
|
+
this._clientBoundaryIssues.forEach(issue => {
|
|
1924
|
+
report += ` - ${issue.filePath}: ${issue.message}\n`;
|
|
1925
|
+
});
|
|
1926
|
+
report += `\n`;
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
if (this._copyFormatters && this._copyFormatters.length > 0) {
|
|
1930
|
+
const suspected = this._copyFormatters.filter(cf => cf.type === 'suspectedCopyFormatter');
|
|
1931
|
+
if (suspected.length > 0) {
|
|
1932
|
+
report += `\n🔧 Suspected Copy Formatters\n`;
|
|
1933
|
+
report += `${'='.repeat(50)}\n`;
|
|
1934
|
+
report += `Functions named "tx" that do not call known translation runtimes. Rename to "copy" or configure "usage.copyFormatters".\n\n`;
|
|
1935
|
+
suspected.forEach(cf => {
|
|
1936
|
+
report += ` - ${cf.filePath}:${cf.line}: ${cf.message}\n`;
|
|
1937
|
+
});
|
|
1938
|
+
report += `\n`;
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1736
1941
|
|
|
1737
1942
|
return report;
|
|
1738
1943
|
}
|
|
@@ -1757,6 +1962,93 @@ Analysis Features (v1.10.1):
|
|
|
1757
1962
|
}
|
|
1758
1963
|
}
|
|
1759
1964
|
|
|
1965
|
+
_computeConfidenceFilteredUnused(allUnused) {
|
|
1966
|
+
const deadKeys = this.findDeadKeys();
|
|
1967
|
+
const deadKeyMap = new Map();
|
|
1968
|
+
for (const dk of deadKeys) {
|
|
1969
|
+
deadKeyMap.set(dk.key, dk);
|
|
1970
|
+
}
|
|
1971
|
+
return allUnused.map(key => ({
|
|
1972
|
+
key,
|
|
1973
|
+
confidence: (deadKeyMap.get(key) || { confidence: 0.9 }).confidence,
|
|
1974
|
+
reason: (deadKeyMap.get(key) || { reason: 'Not found in source' }).reason,
|
|
1975
|
+
}));
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1978
|
+
generateJsonReport(confidenceFilteredUnused) {
|
|
1979
|
+
return {
|
|
1980
|
+
version: this.version,
|
|
1981
|
+
timestamp: new Date().toISOString(),
|
|
1982
|
+
sourceDir: this.sourceDir,
|
|
1983
|
+
i18nDir: this.i18nDir,
|
|
1984
|
+
summary: {
|
|
1985
|
+
availableKeys: this.availableKeys.size,
|
|
1986
|
+
usedKeys: this.usedKeys.size,
|
|
1987
|
+
unusedKeys: confidenceFilteredUnused.length,
|
|
1988
|
+
missingKeys: this.findMissingKeys().length,
|
|
1989
|
+
notTranslated: this.getNotTranslatedStats().total,
|
|
1990
|
+
},
|
|
1991
|
+
unusedKeys: confidenceFilteredUnused,
|
|
1992
|
+
missingKeys: this.findMissingKeys(),
|
|
1993
|
+
dynamicPrefixes: Array.from(this._dynamicPrefixes),
|
|
1994
|
+
unresolvedDynamicReferences: this.unresolvedDynamicReferences,
|
|
1995
|
+
namespaceRecommendations: this.namespaceRecommendations,
|
|
1996
|
+
hardcodedTextCandidates: this.hardcodedTextCandidates,
|
|
1997
|
+
clientBoundaryIssues: this._clientBoundaryIssues || [],
|
|
1998
|
+
mojibakeIssues: this._mojibakeIssues || [],
|
|
1999
|
+
translationCompleteness: Object.fromEntries(this.translationStats),
|
|
2000
|
+
};
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
async saveJsonReport(report, outputDir = './i18ntk-reports/usage') {
|
|
2004
|
+
try {
|
|
2005
|
+
if (!SecurityUtils.safeExistsSync(outputDir)) {
|
|
2006
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
2007
|
+
}
|
|
2008
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
2009
|
+
const filename = `usage-report-${timestamp}.json`;
|
|
2010
|
+
const filepath = path.join(outputDir, filename);
|
|
2011
|
+
const jsonReport = this.generateJsonReport(
|
|
2012
|
+
this._computeConfidenceFilteredUnused(this.findUnusedKeys())
|
|
2013
|
+
);
|
|
2014
|
+
await SecurityUtils.safeWriteFile(filepath, JSON.stringify(jsonReport, null, 2));
|
|
2015
|
+
console.log(t('usage.reportSavedTo', { reportPath: filepath }));
|
|
2016
|
+
return filepath;
|
|
2017
|
+
} catch (error) {
|
|
2018
|
+
console.error(t('usage.failedToSaveReport', { error: error.message }));
|
|
2019
|
+
}
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
pruneReports(outputDir = './i18ntk-reports/usage', keepCount = 10) {
|
|
2023
|
+
try {
|
|
2024
|
+
const resolvedDir = path.resolve(outputDir);
|
|
2025
|
+
if (!SecurityUtils.safeExistsSync(resolvedDir, process.cwd())) {
|
|
2026
|
+
console.log(`Report directory ${outputDir} does not exist.`);
|
|
2027
|
+
return 0;
|
|
2028
|
+
}
|
|
2029
|
+
const items = fs.readdirSync(resolvedDir);
|
|
2030
|
+
const reportFiles = items
|
|
2031
|
+
.filter(f => /^usage-(analysis|report)-.+\.(txt|json)$/.test(f))
|
|
2032
|
+
.map(f => ({ name: f, path: path.join(resolvedDir, f), mtime: fs.statSync(path.join(resolvedDir, f)).mtime }))
|
|
2033
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
2034
|
+
|
|
2035
|
+
if (reportFiles.length <= keepCount) {
|
|
2036
|
+
console.log(`${reportFiles.length} report files, ${keepCount} keep limit. Nothing to prune.`);
|
|
2037
|
+
return 0;
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
const toDelete = reportFiles.slice(keepCount);
|
|
2041
|
+
for (const file of toDelete) {
|
|
2042
|
+
fs.unlinkSync(file.path);
|
|
2043
|
+
}
|
|
2044
|
+
console.log(`Pruned ${toDelete.length} stale report files (kept ${keepCount} most recent).`);
|
|
2045
|
+
return toDelete.length;
|
|
2046
|
+
} catch (error) {
|
|
2047
|
+
console.error(`Failed to prune reports: ${error.message}`);
|
|
2048
|
+
return 0;
|
|
2049
|
+
}
|
|
2050
|
+
}
|
|
2051
|
+
|
|
1760
2052
|
// NEW: Enhanced placeholder key detection with validation
|
|
1761
2053
|
validatePlaceholderKeys(key, value) {
|
|
1762
2054
|
if (typeof value !== 'string') {
|
|
@@ -1831,9 +2123,25 @@ Analysis Features (v1.10.1):
|
|
|
1831
2123
|
/\.instant\(/g
|
|
1832
2124
|
],
|
|
1833
2125
|
score: 0
|
|
2126
|
+
},
|
|
2127
|
+
nextjs: {
|
|
2128
|
+
patterns: [
|
|
2129
|
+
/['"]use server['"]/g,
|
|
2130
|
+
/['"]use client['"]/g,
|
|
2131
|
+
/export\s+default\s+function\s+\w*Page/g,
|
|
2132
|
+
/export\s+async\s+function\s+generate/g,
|
|
2133
|
+
/GetStaticProps|GetServerSideProps/g,
|
|
2134
|
+
/next\/headers/g,
|
|
2135
|
+
/next\/navigation/g,
|
|
2136
|
+
/'server only'/g,
|
|
2137
|
+
/'client only'/g,
|
|
2138
|
+
/getLocale\s*\(/g,
|
|
2139
|
+
/setLocale\s*\(/g,
|
|
2140
|
+
],
|
|
2141
|
+
score: 0
|
|
1834
2142
|
}
|
|
1835
2143
|
};
|
|
1836
|
-
|
|
2144
|
+
|
|
1837
2145
|
const contentStr = String(content || '');
|
|
1838
2146
|
Object.keys(frameworkPatterns).forEach(framework => {
|
|
1839
2147
|
const config = frameworkPatterns[framework];
|
|
@@ -1844,24 +2152,31 @@ Analysis Features (v1.10.1):
|
|
|
1844
2152
|
}
|
|
1845
2153
|
});
|
|
1846
2154
|
});
|
|
1847
|
-
|
|
1848
|
-
// Find dominant framework
|
|
2155
|
+
|
|
1849
2156
|
let dominantFramework = 'generic';
|
|
1850
2157
|
let maxScore = 0;
|
|
1851
|
-
|
|
2158
|
+
|
|
1852
2159
|
Object.keys(frameworkPatterns).forEach(framework => {
|
|
1853
2160
|
if (frameworkPatterns[framework].score > maxScore) {
|
|
1854
2161
|
maxScore = frameworkPatterns[framework].score;
|
|
1855
2162
|
dominantFramework = framework;
|
|
1856
2163
|
}
|
|
1857
2164
|
});
|
|
1858
|
-
|
|
2165
|
+
|
|
2166
|
+
let componentType = null;
|
|
2167
|
+
if (dominantFramework === 'nextjs') {
|
|
2168
|
+
if (/['"]use server['"]/.test(contentStr)) componentType = 'Server Component';
|
|
2169
|
+
else if (/['"]use client['"]/.test(contentStr)) componentType = 'Client Component';
|
|
2170
|
+
else if (/(?:page|layout|loading|error|not-found|template)\.[jt]sx?$/.test(filePath)) componentType = 'App Router (default: Server)';
|
|
2171
|
+
}
|
|
2172
|
+
|
|
1859
2173
|
this.frameworkUsage.set(filePath, {
|
|
1860
2174
|
framework: dominantFramework,
|
|
1861
2175
|
score: maxScore,
|
|
1862
|
-
patterns: frameworkPatterns[dominantFramework]?.patterns || []
|
|
2176
|
+
patterns: frameworkPatterns[dominantFramework]?.patterns || [],
|
|
2177
|
+
componentType,
|
|
1863
2178
|
});
|
|
1864
|
-
|
|
2179
|
+
|
|
1865
2180
|
return dominantFramework;
|
|
1866
2181
|
}
|
|
1867
2182
|
|