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.
@@ -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
- 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
- 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 report = this.generateUsageReport();
650
- 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)`);
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 (!this.keyUsageLocations.has(ref.key)) {
909
- this.keyUsageLocations.set(ref.key, []);
910
- }
911
- this.keyUsageLocations.get(ref.key).push({
912
- filePath: relativePath,
913
- line: ref.line,
914
- column: ref.column,
915
- matchType: ref.matchType,
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._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)) {
1273
1425
  confidence = 0.3;
1274
- reason = 'Key matches dynamic template pattern (likely used)';
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
- _matchesDynamicPattern(key) {
1291
- const keyParts = key.split('.');
1292
- if (keyParts.length < 2) return false;
1293
-
1294
- const dynamicPatterns = [
1295
- /t\(`[^`]*\$\{[^}]*\}[^`]*`\)/g,
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
- for (const pattern of dynamicPatterns) {
1310
- const matches = content.match(pattern);
1311
- if (matches) {
1312
- for (const match of matches) {
1313
- const matchLower = match.toLowerCase();
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 += `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`;
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\n`;
1630
-
1631
- unusedKeys.slice(0, 100).forEach(key => {
1632
- const complexity = this.keyComplexity && this.keyComplexity.get(key);
1633
- const complexityLevel = complexity ? ` (${complexity.level})` : '';
1634
- report += `${t('summary.usageReportUnusedKey', { key: key + complexityLevel })}\n`;
1635
- });
1636
-
1637
- if (unusedKeys.length > 100) {
1638
- 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`;
1639
1795
  }
1640
-
1641
- report += `\n`;
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