i18ntk 4.3.3 → 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.
@@ -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
- displayPaths({ sourceDir: this.sourceDir, i18nDir: this.i18nDir, outputDir: this.config.outputDir });
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
- /i18n\.t\(['"`]([^'"`]+)['"`]/g,
173
- /useTranslation\(\)\.t\(['"`]([^'"`]+)['"`]/g,
174
- /(?<![\w$.])t\s*\(`([^`]+)`\)/g,
175
- /(?<![\w$.])tx\s*\(`([^`]+)`\)/g,
176
- /i18nKey=['"`]([^'"`]+)['"`]/g,
177
- /\$t\(['"`]([^'"`]+)['"`]/g,
178
- /(?<![\w$.])getTranslation\s*\(['"`]([^'"`]+)['"`]/g
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
- /i18n\.t\(['"`]([^'"`]+)['"`]/g,
430
- /useTranslation\(\)\.t\(['"`]([^'"`]+)['"`]/g,
431
- /(?<![\w$.])t\s*\(`([^`]+)`\)/g,
432
- /(?<![\w$.])tx\s*\(`([^`]+)`\)/g,
433
- /i18nKey=['"`]([^'"`]+)['"`]/g,
434
- /\$t\(['"`]([^'"`]+)['"`]/g,
435
- /(?<![\w$.])getTranslation\s*\(['"`]([^'"`]+)['"`]/g
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 report = this.generateUsageReport();
654
- await this.saveReport(report, args.outputDir);
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 (!this.keyUsageLocations.has(ref.key)) {
913
- this.keyUsageLocations.set(ref.key, []);
914
- }
915
- this.keyUsageLocations.get(ref.key).push({
916
- filePath: relativePath,
917
- line: ref.line,
918
- column: ref.column,
919
- matchType: ref.matchType,
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._matchesDynamicPattern(key)) {
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 (likely used)';
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
- _matchesDynamicPattern(key) {
1295
- const keyParts = key.split('.');
1296
- if (keyParts.length < 2) return false;
1297
-
1298
- const dynamicPatterns = [
1299
- /t\(`[^`]*\$\{[^}]*\}[^`]*`\)/g,
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
- for (const pattern of dynamicPatterns) {
1314
- const matches = content.match(pattern);
1315
- if (matches) {
1316
- for (const match of matches) {
1317
- const matchLower = match.toLowerCase();
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 += `Unresolved dynamic expressions: ${this.unresolvedDynamicReferences.length}\n`;
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\n`;
1634
-
1635
- unusedKeys.slice(0, 100).forEach(key => {
1636
- const complexity = this.keyComplexity && this.keyComplexity.get(key);
1637
- const complexityLevel = complexity ? ` (${complexity.level})` : '';
1638
- report += `${t('summary.usageReportUnusedKey', { key: key + complexityLevel })}\n`;
1639
- });
1640
-
1641
- if (unusedKeys.length > 100) {
1642
- report += `${t('summary.usageReportMoreUnusedKeys', { count: unusedKeys.length - 100 })}\n`;
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
- report += `\n`;
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