i18ntk 4.3.3 → 4.4.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/CHANGELOG.md +151 -88
- package/README.md +56 -51
- 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 +20 -8
- package/main/i18ntk-usage.js +438 -127
- package/main/manage/commands/TranslateCommand.js +2 -2
- package/package.json +36 -19
- package/utils/config-helper.js +19 -3
- package/utils/english-placeholder-checker.js +15 -2
- 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,22 +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
175
|
this.config = this.config || {};
|
|
169
|
-
this.config.translationPatterns = this.config.translationPatterns || [
|
|
170
|
-
/(?<![\w$.])t\s*\(['"`]([^'"`]+)['"`]/g,
|
|
171
|
-
/(?<![\w$.])tx\s*\(['"`]([^'"`]+)['"`]/g,
|
|
172
|
-
|
|
173
|
-
/
|
|
174
|
-
/
|
|
175
|
-
/(
|
|
176
|
-
/
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
176
|
+
this.config.translationPatterns = this.config.translationPatterns || [
|
|
177
|
+
/(?<![\w$.])t\s*\(['"`]([^'"`]+)['"`]/g,
|
|
178
|
+
/(?<![\w$.])tx\s*\(['"`]([^'"`]+)['"`]/g,
|
|
179
|
+
/\.tx\s*\(['"`]([^'"`]+)['"`]/g,
|
|
180
|
+
/(?<=[)\s])\.tx\s*\(['"`]([^'"`]+)['"`]/g,
|
|
181
|
+
/i18n\.t\(['"`]([^'"`]+)['"`]/g,
|
|
182
|
+
/useTranslation\(\)\.t\(['"`]([^'"`]+)['"`]/g,
|
|
183
|
+
/(?<![\w$.])t\s*\(`([^`]+)`\)/g,
|
|
184
|
+
/(?<![\w$.])tx\s*\(`([^`]+)`\)/g,
|
|
185
|
+
/\.tx\s*\(`([^`]+)`\)/g,
|
|
186
|
+
/i18nKey=['"`]([^'"`]+)['"`]/g,
|
|
187
|
+
/\$t\(['"`]([^'"`]+)['"`]/g,
|
|
188
|
+
/(?<![\w$.])getTranslation\s*\(['"`]([^'"`]+)['"`]/g
|
|
189
|
+
];
|
|
180
190
|
this.extractor = getExtractor(this.config.extractor);
|
|
181
191
|
|
|
182
192
|
// Ensure defaults for other config values
|
|
@@ -208,7 +218,11 @@ class I18nUsageAnalyzer {
|
|
|
208
218
|
strict: a.strict,
|
|
209
219
|
debug: a.debug,
|
|
210
220
|
cleanup: a.cleanup ?? a['cleanup'],
|
|
211
|
-
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
|
|
212
226
|
};
|
|
213
227
|
}
|
|
214
228
|
|
|
@@ -403,6 +417,12 @@ class I18nUsageAnalyzer {
|
|
|
403
417
|
if (toBool(args.dryRunDelete)) {
|
|
404
418
|
this.dryRunDelete = true;
|
|
405
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
|
+
}
|
|
406
426
|
|
|
407
427
|
try {
|
|
408
428
|
// Ensure config is always initialized
|
|
@@ -422,18 +442,20 @@ class I18nUsageAnalyzer {
|
|
|
422
442
|
|
|
423
443
|
const uiLanguage = (this.config && this.config.uiLanguage) || 'en';
|
|
424
444
|
loadTranslations(uiLanguage, path.resolve(__dirname, '..', 'ui-locales'));
|
|
425
|
-
if (!Array.isArray(this.config.translationPatterns)) {
|
|
426
|
-
this.config.translationPatterns = [
|
|
427
|
-
/(?<![\w$.])t\s*\(['"`]([^'"`]+)['"`]/g,
|
|
428
|
-
/(?<![\w$.])tx\s*\(['"`]([^'"`]+)['"`]/g,
|
|
429
|
-
|
|
430
|
-
/
|
|
431
|
-
/(
|
|
432
|
-
/(?<![\w$.])
|
|
433
|
-
/
|
|
434
|
-
|
|
435
|
-
/
|
|
436
|
-
|
|
445
|
+
if (!Array.isArray(this.config.translationPatterns)) {
|
|
446
|
+
this.config.translationPatterns = [
|
|
447
|
+
/(?<![\w$.])t\s*\(['"`]([^'"`]+)['"`]/g,
|
|
448
|
+
/(?<![\w$.])tx\s*\(['"`]([^'"`]+)['"`]/g,
|
|
449
|
+
/\.tx\s*\(['"`]([^'"`]+)['"`]/g,
|
|
450
|
+
/i18n\.t\(['"`]([^'"`]+)['"`]/g,
|
|
451
|
+
/useTranslation\(\)\.t\(['"`]([^'"`]+)['"`]/g,
|
|
452
|
+
/(?<![\w$.])t\s*\(`([^`]+)`\)/g,
|
|
453
|
+
/(?<![\w$.])tx\s*\(`([^`]+)`\)/g,
|
|
454
|
+
/\.tx\s*\(`([^`]+)`\)/g,
|
|
455
|
+
/i18nKey=['"`]([^'"`]+)['"`]/g,
|
|
456
|
+
/\$t\(['"`]([^'"`]+)['"`]/g,
|
|
457
|
+
/(?<![\w$.])getTranslation\s*\(['"`]([^'"`]+)['"`]/g
|
|
458
|
+
];
|
|
437
459
|
}
|
|
438
460
|
if (!Array.isArray(this.config.excludeDirs)) {
|
|
439
461
|
this.config.excludeDirs = ['node_modules', '.git'];
|
|
@@ -538,6 +560,8 @@ class I18nUsageAnalyzer {
|
|
|
538
560
|
});
|
|
539
561
|
}
|
|
540
562
|
|
|
563
|
+
displayPaths({ sourceDir: this.sourceDir, i18nDir: this.i18nDir, outputDir: this.config.outputDir });
|
|
564
|
+
|
|
541
565
|
console.log(t('usage.detectedSourceDirectory', { sourceDir: this.sourceDir || t('usage.noSourceDirectoryConfigured') || '(none)' }));
|
|
542
566
|
console.log(t('usage.detectedI18nDirectory', { i18nDir: this.i18nDir }));
|
|
543
567
|
|
|
@@ -650,8 +674,50 @@ class I18nUsageAnalyzer {
|
|
|
650
674
|
}
|
|
651
675
|
|
|
652
676
|
if (args.outputReport) {
|
|
653
|
-
const
|
|
654
|
-
|
|
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)`);
|
|
655
721
|
}
|
|
656
722
|
|
|
657
723
|
console.log('\n' + t('usage.analysisCompletedSuccessfully'));
|
|
@@ -687,6 +753,8 @@ Options:
|
|
|
687
753
|
--output-report Generate detailed usage report
|
|
688
754
|
--output-dir=<path> Directory for output reports (default: ./i18ntk-reports/usage)
|
|
689
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)
|
|
690
758
|
--debug Enable debug mode with stack traces
|
|
691
759
|
--no-prompt Skip interactive prompts (useful for CI/CD)
|
|
692
760
|
--validate-placeholders Enable placeholder key validation
|
|
@@ -703,7 +771,7 @@ Examples:
|
|
|
703
771
|
node i18ntk-usage.js --cleanup --dry-run-delete
|
|
704
772
|
|
|
705
773
|
Analysis Features (v1.10.1):
|
|
706
|
-
• Detects unused translation keys
|
|
774
|
+
• Detects unused translation keys with confidence scoring
|
|
707
775
|
• Identifies missing translation keys
|
|
708
776
|
• Shows translation completeness by language
|
|
709
777
|
• Reports NOT_TRANSLATED values
|
|
@@ -712,13 +780,19 @@ Analysis Features (v1.10.1):
|
|
|
712
780
|
• Framework-specific pattern recognition (React, Vue, Angular)
|
|
713
781
|
• Advanced translation completeness scoring
|
|
714
782
|
• Performance metrics and optimization tracking
|
|
715
|
-
• Key complexity analysis
|
|
716
|
-
• Known-key literal matching with source file locations
|
|
717
|
-
• Namespace/file naming recommendations (for example app/shop -> shop.json)
|
|
718
|
-
• 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
|
|
719
787
|
• Security-enhanced path validation
|
|
720
788
|
• Detailed reporting with validation errors
|
|
721
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)
|
|
722
796
|
`);
|
|
723
797
|
}
|
|
724
798
|
|
|
@@ -849,6 +923,17 @@ Analysis Features (v1.10.1):
|
|
|
849
923
|
return keys;
|
|
850
924
|
}
|
|
851
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
|
+
|
|
852
937
|
collectPlaceholderKeys(obj, prefix = '', language) {
|
|
853
938
|
const patterns = this.placeholderStyles[language] || [];
|
|
854
939
|
const regexes = patterns.reduce((compiled, pattern) => {
|
|
@@ -899,25 +984,54 @@ Analysis Features (v1.10.1):
|
|
|
899
984
|
return this.extractor.extract(content, rawPatterns);
|
|
900
985
|
}
|
|
901
986
|
|
|
902
|
-
recordUsageInsights(relativePath, insights) {
|
|
903
|
-
const keys = [];
|
|
904
|
-
const seen = new Set();
|
|
905
|
-
|
|
906
|
-
for (const ref of insights.keyReferences || []) {
|
|
907
|
-
if (!ref.key || seen.has(ref.key)) continue;
|
|
908
|
-
seen.add(ref.key);
|
|
909
|
-
keys.push(ref.key);
|
|
910
|
-
this.usedKeys.add(ref.key);
|
|
911
|
-
|
|
912
|
-
if (
|
|
913
|
-
this.
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
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
|
+
});
|
|
921
1035
|
}
|
|
922
1036
|
|
|
923
1037
|
if (keys.length > 0) {
|
|
@@ -935,13 +1049,31 @@ Analysis Features (v1.10.1):
|
|
|
935
1049
|
this.hardcodedTextCandidates.push(...insights.hardcodedTexts);
|
|
936
1050
|
}
|
|
937
1051
|
|
|
938
|
-
if (Array.isArray(insights.unresolvedDynamicReferences) && insights.unresolvedDynamicReferences.length > 0) {
|
|
939
|
-
this.unresolvedDynamicReferences.push(...insights.unresolvedDynamicReferences.map(ref => ({
|
|
940
|
-
filePath: relativePath,
|
|
941
|
-
...ref,
|
|
942
|
-
})));
|
|
943
|
-
|
|
944
|
-
|
|
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
|
+
|
|
945
1077
|
return keys.length;
|
|
946
1078
|
}
|
|
947
1079
|
|
|
@@ -1139,6 +1271,19 @@ Analysis Features (v1.10.1):
|
|
|
1139
1271
|
const stats = this.analyzeFileCompleteness(jsonData);
|
|
1140
1272
|
totalKeys += stats.total;
|
|
1141
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
|
+
}
|
|
1142
1287
|
} catch (error) {
|
|
1143
1288
|
if (isDebug || isStrict) {
|
|
1144
1289
|
console.warn(`❌ Failed to analyze file ${path.basename(fileInfo.filePath)}: ${error.message}`);
|
|
@@ -1273,9 +1418,12 @@ Analysis Features (v1.10.1):
|
|
|
1273
1418
|
let confidence = 0.9;
|
|
1274
1419
|
let reason = 'Key not found in any source file';
|
|
1275
1420
|
|
|
1276
|
-
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)) {
|
|
1277
1425
|
confidence = 0.3;
|
|
1278
|
-
reason = 'Key matches dynamic template pattern
|
|
1426
|
+
reason = 'Key prefix matches unresolved dynamic template pattern';
|
|
1279
1427
|
} else if (this._keyInSourceComments(key)) {
|
|
1280
1428
|
confidence = 0.5;
|
|
1281
1429
|
reason = 'Key referenced in comments/JSDoc';
|
|
@@ -1291,39 +1439,19 @@ Analysis Features (v1.10.1):
|
|
|
1291
1439
|
return deadKeys;
|
|
1292
1440
|
}
|
|
1293
1441
|
|
|
1294
|
-
|
|
1295
|
-
const
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
/i18n\.t\(`[^`]*\$\{[^}]*\}[^`]*`\)/g,
|
|
1301
|
-
/useTranslation\(\)\.t\(`[^`]*\$\{[^}]*\}[^`]*`\)/g
|
|
1302
|
-
];
|
|
1303
|
-
|
|
1304
|
-
try {
|
|
1305
|
-
const sourceFiles = Array.from(this.fileUsage.keys());
|
|
1306
|
-
for (const filePath of sourceFiles) {
|
|
1307
|
-
const fullPath = path.join(this.sourceDir, filePath);
|
|
1308
|
-
if (!SecurityUtils.safeExistsSync(fullPath, this.sourceDir)) continue;
|
|
1309
|
-
|
|
1310
|
-
const content = SecurityUtils.safeReadFileSync(fullPath, this.sourceDir, 'utf8');
|
|
1311
|
-
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
|
+
}
|
|
1312
1448
|
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
if (keyParts.some(part => matchLower.includes(part.toLowerCase()))) {
|
|
1319
|
-
return true;
|
|
1320
|
-
}
|
|
1321
|
-
}
|
|
1322
|
-
}
|
|
1323
|
-
}
|
|
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;
|
|
1324
1454
|
}
|
|
1325
|
-
} catch (e) {
|
|
1326
|
-
// Silently fail - dynamic pattern detection is best-effort
|
|
1327
1455
|
}
|
|
1328
1456
|
|
|
1329
1457
|
return false;
|
|
@@ -1566,8 +1694,10 @@ Analysis Features (v1.10.1):
|
|
|
1566
1694
|
report += `${'='.repeat(50)}\n`;
|
|
1567
1695
|
report += `Direct i18n calls: ${matchCounts.direct || 0}\n`;
|
|
1568
1696
|
report += `Known-key literal matches: ${matchCounts.literal || 0}\n`;
|
|
1569
|
-
report += `Resolved dynamic expressions: ${(matchCounts['dynamic-template'] || 0) + (matchCounts['dynamic-variable'] || 0)}\n`;
|
|
1570
|
-
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`;
|
|
1571
1701
|
report += `Indexed keys with file locations: ${this.keyUsageLocations.size}\n\n`;
|
|
1572
1702
|
|
|
1573
1703
|
const indexedKeys = Array.from(this.keyUsageLocations.entries()).slice(0, 100);
|
|
@@ -1598,18 +1728,35 @@ Analysis Features (v1.10.1):
|
|
|
1598
1728
|
report += `\n`;
|
|
1599
1729
|
}
|
|
1600
1730
|
|
|
1601
|
-
if (this.unresolvedDynamicReferences.length > 0) {
|
|
1602
|
-
report += `🧩 Unresolved Dynamic Key Expressions\n`;
|
|
1603
|
-
report += `${'='.repeat(50)}\n`;
|
|
1604
|
-
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`;
|
|
1605
|
-
this.unresolvedDynamicReferences.slice(0, 50).forEach(item => {
|
|
1606
|
-
const prefix = item.prefix ? ` prefix: ${item.prefix}` : ' no static prefix';
|
|
1607
|
-
report += `- ${item.filePath}:${item.line} ${item.expression} (${prefix})\n`;
|
|
1608
|
-
});
|
|
1609
|
-
if (this.unresolvedDynamicReferences.length > 50) {
|
|
1610
|
-
report += `... ${this.unresolvedDynamicReferences.length - 50} more unresolved expressions\n`;
|
|
1611
|
-
}
|
|
1612
|
-
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`;
|
|
1613
1760
|
}
|
|
1614
1761
|
|
|
1615
1762
|
if (this.hardcodedTextCandidates.length > 0) {
|
|
@@ -1626,24 +1773,45 @@ Analysis Features (v1.10.1):
|
|
|
1626
1773
|
report += `\n`;
|
|
1627
1774
|
}
|
|
1628
1775
|
|
|
1629
|
-
// Unused keys with complexity
|
|
1776
|
+
// Unused keys with complexity and confidence
|
|
1630
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
|
+
|
|
1631
1783
|
report += `${t('summary.usageReportUnusedTranslationKeys')}\n`;
|
|
1632
1784
|
report += `${'='.repeat(50)}\n`;
|
|
1633
|
-
report += `${t('summary.usageReportUnusedKeysDescription')}\n
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
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`;
|
|
1643
1795
|
}
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
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
|
+
}
|
|
1647
1815
|
|
|
1648
1816
|
// Missing keys with location and framework
|
|
1649
1817
|
if (missingKeys.length > 0) {
|
|
@@ -1737,6 +1905,39 @@ Analysis Features (v1.10.1):
|
|
|
1737
1905
|
if (this.fileUsage.size > 20) {
|
|
1738
1906
|
report += `${t('summary.usageReportMoreFiles', { count: this.fileUsage.size - 20 })}\n`;
|
|
1739
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
|
+
}
|
|
1740
1941
|
|
|
1741
1942
|
return report;
|
|
1742
1943
|
}
|
|
@@ -1761,6 +1962,93 @@ Analysis Features (v1.10.1):
|
|
|
1761
1962
|
}
|
|
1762
1963
|
}
|
|
1763
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
|
+
|
|
1764
2052
|
// NEW: Enhanced placeholder key detection with validation
|
|
1765
2053
|
validatePlaceholderKeys(key, value) {
|
|
1766
2054
|
if (typeof value !== 'string') {
|
|
@@ -1835,9 +2123,25 @@ Analysis Features (v1.10.1):
|
|
|
1835
2123
|
/\.instant\(/g
|
|
1836
2124
|
],
|
|
1837
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
|
|
1838
2142
|
}
|
|
1839
2143
|
};
|
|
1840
|
-
|
|
2144
|
+
|
|
1841
2145
|
const contentStr = String(content || '');
|
|
1842
2146
|
Object.keys(frameworkPatterns).forEach(framework => {
|
|
1843
2147
|
const config = frameworkPatterns[framework];
|
|
@@ -1848,24 +2152,31 @@ Analysis Features (v1.10.1):
|
|
|
1848
2152
|
}
|
|
1849
2153
|
});
|
|
1850
2154
|
});
|
|
1851
|
-
|
|
1852
|
-
// Find dominant framework
|
|
2155
|
+
|
|
1853
2156
|
let dominantFramework = 'generic';
|
|
1854
2157
|
let maxScore = 0;
|
|
1855
|
-
|
|
2158
|
+
|
|
1856
2159
|
Object.keys(frameworkPatterns).forEach(framework => {
|
|
1857
2160
|
if (frameworkPatterns[framework].score > maxScore) {
|
|
1858
2161
|
maxScore = frameworkPatterns[framework].score;
|
|
1859
2162
|
dominantFramework = framework;
|
|
1860
2163
|
}
|
|
1861
2164
|
});
|
|
1862
|
-
|
|
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
|
+
|
|
1863
2173
|
this.frameworkUsage.set(filePath, {
|
|
1864
2174
|
framework: dominantFramework,
|
|
1865
2175
|
score: maxScore,
|
|
1866
|
-
patterns: frameworkPatterns[dominantFramework]?.patterns || []
|
|
2176
|
+
patterns: frameworkPatterns[dominantFramework]?.patterns || [],
|
|
2177
|
+
componentType,
|
|
1867
2178
|
});
|
|
1868
|
-
|
|
2179
|
+
|
|
1869
2180
|
return dominantFramework;
|
|
1870
2181
|
}
|
|
1871
2182
|
|