i18nsmith 0.2.0 → 0.3.0

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.
Files changed (55) hide show
  1. package/dist/commands/audit.d.ts.map +1 -1
  2. package/dist/commands/backup.d.ts.map +1 -1
  3. package/dist/commands/check.d.ts +25 -0
  4. package/dist/commands/check.d.ts.map +1 -1
  5. package/dist/commands/config.d.ts.map +1 -1
  6. package/dist/commands/debug-patterns.d.ts.map +1 -1
  7. package/dist/commands/diagnose.d.ts.map +1 -1
  8. package/dist/commands/init.d.ts.map +1 -1
  9. package/dist/commands/install-hooks.d.ts.map +1 -1
  10. package/dist/commands/preflight.d.ts.map +1 -1
  11. package/dist/commands/rename.d.ts.map +1 -1
  12. package/dist/commands/review.d.ts.map +1 -1
  13. package/dist/commands/scaffold-adapter.d.ts.map +1 -1
  14. package/dist/commands/scan.d.ts.map +1 -1
  15. package/dist/commands/sync.d.ts +1 -1
  16. package/dist/commands/sync.d.ts.map +1 -1
  17. package/dist/commands/transform.d.ts.map +1 -1
  18. package/dist/commands/translate/index.d.ts.map +1 -1
  19. package/dist/index.js +2574 -107704
  20. package/dist/rename-suspicious.test.d.ts +2 -0
  21. package/dist/rename-suspicious.test.d.ts.map +1 -0
  22. package/dist/utils/diff-utils.d.ts +5 -0
  23. package/dist/utils/diff-utils.d.ts.map +1 -1
  24. package/dist/utils/errors.d.ts +8 -0
  25. package/dist/utils/errors.d.ts.map +1 -0
  26. package/dist/utils/locale-audit.d.ts +39 -0
  27. package/dist/utils/locale-audit.d.ts.map +1 -0
  28. package/dist/utils/preview.d.ts.map +1 -1
  29. package/dist/utils/preview.test.d.ts +2 -0
  30. package/dist/utils/preview.test.d.ts.map +1 -0
  31. package/package.json +5 -5
  32. package/src/commands/audit.ts +18 -209
  33. package/src/commands/backup.ts +67 -63
  34. package/src/commands/check.ts +119 -68
  35. package/src/commands/config.ts +117 -95
  36. package/src/commands/debug-patterns.ts +25 -22
  37. package/src/commands/diagnose.ts +29 -26
  38. package/src/commands/init.ts +84 -79
  39. package/src/commands/install-hooks.ts +18 -15
  40. package/src/commands/preflight.ts +21 -13
  41. package/src/commands/rename.ts +86 -81
  42. package/src/commands/review.ts +81 -78
  43. package/src/commands/scaffold-adapter.ts +8 -4
  44. package/src/commands/scan.ts +61 -58
  45. package/src/commands/sync.ts +640 -203
  46. package/src/commands/transform.ts +117 -8
  47. package/src/commands/translate/index.ts +7 -4
  48. package/src/e2e.test.ts +78 -14
  49. package/src/integration.test.ts +86 -0
  50. package/src/rename-suspicious.test.ts +124 -0
  51. package/src/utils/diff-utils.ts +6 -0
  52. package/src/utils/errors.ts +34 -0
  53. package/src/utils/locale-audit.ts +219 -0
  54. package/src/utils/preview.test.ts +137 -0
  55. package/src/utils/preview.ts +2 -8
@@ -1,8 +1,8 @@
1
- import { Command } from 'commander';
2
- import chalk from 'chalk';
3
- import inquirer, { type CheckboxQuestion } from 'inquirer';
4
- import { promises as fs } from 'fs';
5
- import path from 'path';
1
+ import { Command } from "commander";
2
+ import chalk from "chalk";
3
+ import inquirer, { type CheckboxQuestion } from "inquirer";
4
+ import { promises as fs } from "fs";
5
+ import path from "path";
6
6
  import {
7
7
  Syncer,
8
8
  KeyRenamer,
@@ -14,13 +14,15 @@ import {
14
14
  type SyncSummary,
15
15
  type KeyRenameBatchSummary,
16
16
  type SyncSelection,
17
- } from '@i18nsmith/core';
17
+ } from "@i18nsmith/core";
18
18
  import {
19
19
  printLocaleDiffs,
20
20
  writeLocaleDiffPatches,
21
- } from '../utils/diff-utils.js';
22
- import { applyPreviewFile, writePreviewFile } from '../utils/preview.js';
23
- import { SYNC_EXIT_CODES } from '../utils/exit-codes.js';
21
+ } from "../utils/diff-utils.js";
22
+ import { applyPreviewFile, writePreviewFile } from "../utils/preview.js";
23
+ import { SYNC_EXIT_CODES } from "../utils/exit-codes.js";
24
+ import { runCheck } from "./check.js";
25
+ import { withErrorHandling } from "../utils/errors.js";
24
26
 
25
27
  interface SyncCommandOptions {
26
28
  config?: string;
@@ -46,8 +48,8 @@ interface SyncCommandOptions {
46
48
  invalidateCache?: boolean;
47
49
  autoRenameSuspicious?: boolean;
48
50
  renameMapFile?: string;
49
- namingConvention?: 'kebab-case' | 'camelCase' | 'snake_case';
50
- rewriteShape?: 'flat' | 'nested';
51
+ namingConvention?: "kebab-case" | "camelCase" | "snake_case";
52
+ rewriteShape?: "flat" | "nested";
51
53
  shapeDelimiter?: string;
52
54
  seedTargetLocales?: boolean;
53
55
  seedValue?: string;
@@ -57,57 +59,219 @@ interface SyncCommandOptions {
57
59
  }
58
60
 
59
61
  function collectAssumedKeys(value: string, previous: string[] = []) {
60
- return previous.concat(value.split(',').map((k) => k.trim()));
62
+ return previous.concat(value.split(",").map((k) => k.trim()));
61
63
  }
62
64
 
63
65
  function collectTargetPatterns(value: string, previous: string[] = []) {
64
66
  return previous.concat(value);
65
67
  }
66
68
 
69
+ const SYNC_HELP_SECTIONS = `
70
+ Examples:
71
+ $ i18nsmith sync # analyze without writing changes
72
+ $ i18nsmith sync --write --prune --yes # prune unused keys non-interactively
73
+ $ i18nsmith sync --preview-output sync-preview.json
74
+
75
+ Option groups:
76
+ General workflow:
77
+ --write, --prune, --no-backup, --yes, --selection-file
78
+ Safety & CI:
79
+ --strict, --validate-interpolations, --no-empty-values, --check, --interactive
80
+ Targeting & detection scope:
81
+ --target, --include, --exclude, --assume, --assume-globs, --invalidate-cache
82
+ Output, previews & diffs:
83
+ --json, --report, --diff, --patch-dir, --preview-output, --apply-preview
84
+ Automation & renaming:
85
+ --auto-rename-suspicious, --rename-map-file, --naming-convention
86
+ Locale shaping & seeding:
87
+ --rewrite-shape, --shape-delimiter, --seed-target-locales, --seed-value
88
+ `;
89
+
67
90
  export function registerSync(program: Command) {
68
91
  program
69
- .command('sync')
70
- .description('Detect missing locale keys and optionally prune unused entries')
71
- .option('-c, --config <path>', 'Path to i18nsmith config file', 'i18n.config.json')
72
- .option('--json', 'Print raw JSON results', false)
73
- .option('--report <path>', 'Write JSON summary to a file (for CI or editors)')
74
- .option('--write', 'Write changes to disk (defaults to dry-run)', false)
75
- .option('--prune', 'Remove unused keys from locale files (requires --write)', false)
76
- .option('--no-backup', 'Disable automatic backup when using --prune (backup is on by default with --prune)')
77
- .option('-y, --yes', 'Skip confirmation prompts (for CI)', false)
78
- .option('--check', 'Exit with error code if drift detected', false)
79
- .option('--strict', 'Exit with error code if any suspicious patterns detected (CI mode)', false)
80
- .option('--validate-interpolations', 'Validate interpolation placeholders across locales', false)
81
- .option('--no-empty-values', 'Treat empty or placeholder locale values as failures')
82
- .option('--assume <keys...>', 'List of runtime keys to assume present (comma-separated)', collectAssumedKeys, [])
83
- .option('--assume-globs <patterns...>', 'Glob patterns for dynamic key namespaces (e.g., errors.*, navigation.**)', collectTargetPatterns, [])
84
- .option('--interactive', 'Interactively approve locale mutations before writing', false)
85
- .option('--diff', 'Display unified diffs for locale files that would change', false)
86
- .option('--patch-dir <path>', 'Write locale diffs to .patch files in the specified directory')
87
- .option('--invalidate-cache', 'Ignore cached sync analysis and rescan all source files', false)
88
- .option('--target <pattern...>', 'Limit translation reference scanning to specific files or glob patterns', collectTargetPatterns, [])
89
- .option('--include <patterns...>', 'Override include globs from config (comma or space separated)', collectTargetPatterns, [])
90
- .option('--exclude <patterns...>', 'Override exclude globs from config (comma or space separated)', collectTargetPatterns, [])
91
- .option('--auto-rename-suspicious', 'Propose normalized names for suspicious keys', false)
92
- .option('--rename-map-file <path>', 'Write rename proposals to a mapping file (JSON or commented format)')
93
- .option('--naming-convention <convention>', 'Naming convention for auto-rename (kebab-case, camelCase, snake_case)', 'kebab-case')
94
- .option('--rewrite-shape <format>', 'Rewrite all locale files to flat or nested format')
95
- .option('--shape-delimiter <char>', 'Delimiter for key nesting (default: ".")', '.')
96
- .option('--seed-target-locales', 'Add missing keys to target locale files with empty or placeholder values', false)
97
- .option('--seed-value <value>', 'Value to use when seeding target locales (default: empty string)', '')
98
- .option('--preview-output <path>', 'Write preview summary (JSON) to a file (implies dry-run)')
99
- .option('--selection-file <path>', 'Path to JSON file with selected missing/unused keys to write (used with --write)')
100
- .option('--apply-preview <path>', 'Apply a previously saved sync preview JSON file safely')
101
- .action(async (options: SyncCommandOptions) => {
92
+ .command("sync")
93
+ .description(
94
+ "Detect missing locale keys and optionally prune unused entries"
95
+ )
96
+ .option(
97
+ "-c, --config <path>",
98
+ "Path to i18nsmith config file",
99
+ "i18n.config.json"
100
+ )
101
+ .option("--json", "Print raw JSON results", false)
102
+ .option(
103
+ "--report <path>",
104
+ "Write JSON summary to a file (for CI or editors)"
105
+ )
106
+ .option("--write", "Write changes to disk (defaults to dry-run)", false)
107
+ .option(
108
+ "--prune",
109
+ "Remove unused keys from locale files (requires --write)",
110
+ false
111
+ )
112
+ .option(
113
+ "--no-backup",
114
+ "Disable automatic backup when using --prune (backup is on by default with --prune)"
115
+ )
116
+ .option("-y, --yes", "Skip confirmation prompts (for CI)", false)
117
+ .option(
118
+ "--check",
119
+ 'Alias for "i18nsmith check" (runs the health report instead of sync)',
120
+ false
121
+ )
122
+ .option(
123
+ "--strict",
124
+ "Exit with error code if any suspicious patterns detected (CI mode)",
125
+ false
126
+ )
127
+ .option(
128
+ "--validate-interpolations",
129
+ "Validate interpolation placeholders across locales",
130
+ false
131
+ )
132
+ .option(
133
+ "--no-empty-values",
134
+ "Treat empty or placeholder locale values as failures"
135
+ )
136
+ .option(
137
+ "--assume <keys...>",
138
+ "List of runtime keys to assume present (comma-separated)",
139
+ collectAssumedKeys,
140
+ []
141
+ )
142
+ .option(
143
+ "--assume-globs <patterns...>",
144
+ "Glob patterns for dynamic key namespaces (e.g., errors.*, navigation.**)",
145
+ collectTargetPatterns,
146
+ []
147
+ )
148
+ .option(
149
+ "--interactive",
150
+ "Interactively approve locale mutations before writing",
151
+ false
152
+ )
153
+ .option(
154
+ "--diff",
155
+ "Display unified diffs for locale files that would change",
156
+ false
157
+ )
158
+ .option(
159
+ "--patch-dir <path>",
160
+ "Write locale diffs to .patch files in the specified directory"
161
+ )
162
+ .option(
163
+ "--invalidate-cache",
164
+ "Ignore cached sync analysis and rescan all source files",
165
+ false
166
+ )
167
+ .option(
168
+ "--target <pattern...>",
169
+ "Limit translation reference scanning to specific files or glob patterns",
170
+ collectTargetPatterns,
171
+ []
172
+ )
173
+ .option(
174
+ "--include <patterns...>",
175
+ "Override include globs from config (comma or space separated)",
176
+ collectTargetPatterns,
177
+ []
178
+ )
179
+ .option(
180
+ "--exclude <patterns...>",
181
+ "Override exclude globs from config (comma or space separated)",
182
+ collectTargetPatterns,
183
+ []
184
+ )
185
+ .option(
186
+ "--auto-rename-suspicious",
187
+ "Propose normalized names for suspicious keys",
188
+ false
189
+ )
190
+ .option(
191
+ "--rename-map-file <path>",
192
+ "Write rename proposals to a mapping file (JSON or commented format)"
193
+ )
194
+ .option(
195
+ "--naming-convention <convention>",
196
+ "Naming convention for auto-rename (kebab-case, camelCase, snake_case)",
197
+ "kebab-case"
198
+ )
199
+ .option(
200
+ "--rewrite-shape <format>",
201
+ "Rewrite all locale files to flat or nested format"
202
+ )
203
+ .option(
204
+ "--shape-delimiter <char>",
205
+ 'Delimiter for key nesting (default: ".")',
206
+ "."
207
+ )
208
+ .option(
209
+ "--seed-target-locales",
210
+ "Add missing keys to target locale files with empty or placeholder values",
211
+ false
212
+ )
213
+ .option(
214
+ "--seed-value <value>",
215
+ "Value to use when seeding target locales (default: empty string)",
216
+ ""
217
+ )
218
+ .option(
219
+ "--preview-output <path>",
220
+ "Write preview summary (JSON) to a file (implies dry-run)"
221
+ )
222
+ .option(
223
+ "--selection-file <path>",
224
+ "Path to JSON file with selected missing/unused keys to write (used with --write)"
225
+ )
226
+ .option(
227
+ "--apply-preview <path>",
228
+ "Apply a previously saved sync preview JSON file safely"
229
+ )
230
+ .addHelpText("after", SYNC_HELP_SECTIONS)
231
+ .action(
232
+ withErrorHandling(async (options: SyncCommandOptions) => {
233
+ if (options.check) {
234
+ console.log(
235
+ chalk.yellow(
236
+ "`sync --check` now runs the guided health check. Redirecting to `i18nsmith check`..."
237
+ )
238
+ );
239
+ await runCheck({
240
+ config: options.config,
241
+ json: options.json,
242
+ report: options.report,
243
+ target: options.target,
244
+ assume: options.assume,
245
+ assumeGlobs: options.assumeGlobs,
246
+ validateInterpolations: options.validateInterpolations,
247
+ emptyValues: options.emptyValues,
248
+ diff: options.diff,
249
+ invalidateCache: options.invalidateCache,
250
+ failOn: options.strict ? "warnings" : "conflicts",
251
+ audit: options.strict,
252
+ auditStrict: options.strict,
253
+ });
254
+ return;
255
+ }
256
+
102
257
  if (options.applyPreview) {
103
258
  const extraArgs: string[] = [];
104
259
  if (options.selectionFile) {
105
- extraArgs.push('--selection-file', options.selectionFile);
260
+ extraArgs.push("--selection-file", options.selectionFile);
106
261
  }
107
262
  if (options.prune) {
108
- extraArgs.push('--prune');
263
+ extraArgs.push("--prune");
109
264
  }
110
- await applyPreviewFile('sync', options.applyPreview, extraArgs);
265
+ if (options.yes) {
266
+ extraArgs.push("--yes");
267
+ }
268
+ if (options.seedTargetLocales) {
269
+ extraArgs.push("--seed-target-locales");
270
+ }
271
+ if (options.seedValue) {
272
+ extraArgs.push("--seed-value", options.seedValue);
273
+ }
274
+ await applyPreviewFile("sync", options.applyPreview, extraArgs);
111
275
  return;
112
276
  }
113
277
 
@@ -118,13 +282,17 @@ export function registerSync(program: Command) {
118
282
  const diffRequested = diffEnabled || Boolean(options.json) || previewMode;
119
283
 
120
284
  if (interactive && options.json) {
121
- console.error(chalk.red('--interactive cannot be combined with --json output.'));
285
+ console.error(
286
+ chalk.red("--interactive cannot be combined with --json output.")
287
+ );
122
288
  process.exitCode = 1;
123
289
  return;
124
290
  }
125
291
 
126
292
  if (previewMode && interactive) {
127
- console.error(chalk.red('--preview-output cannot be combined with --interactive.'));
293
+ console.error(
294
+ chalk.red("--preview-output cannot be combined with --interactive.")
295
+ );
128
296
  process.exitCode = 1;
129
297
  return;
130
298
  }
@@ -132,13 +300,19 @@ export function registerSync(program: Command) {
132
300
  const writeEnabled = Boolean(options.write) && !previewMode;
133
301
  if (previewMode && options.write) {
134
302
  console.log(
135
- chalk.yellow('Preview requested; ignoring --write and running in dry-run mode.')
303
+ chalk.yellow(
304
+ "Preview requested; ignoring --write and running in dry-run mode."
305
+ )
136
306
  );
137
307
  }
138
308
  options.write = writeEnabled;
139
309
 
140
310
  if (options.selectionFile && !options.write) {
141
- console.error(chalk.red('--selection-file requires --write (or --apply-preview) to take effect.'));
311
+ console.error(
312
+ chalk.red(
313
+ "--selection-file requires --write (or --apply-preview) to take effect."
314
+ )
315
+ );
142
316
  process.exitCode = 1;
143
317
  return;
144
318
  }
@@ -148,24 +322,28 @@ export function registerSync(program: Command) {
148
322
  : undefined;
149
323
 
150
324
  const banner = previewMode
151
- ? 'Generating sync preview...'
325
+ ? "Generating sync preview..."
152
326
  : interactive
153
- ? 'Interactive sync (dry-run first)...'
154
- : writeEnabled
155
- ? 'Syncing locale files...'
156
- : 'Checking locale drift...';
327
+ ? "Interactive sync (dry-run first)..."
328
+ : writeEnabled
329
+ ? "Syncing locale files..."
330
+ : "Checking locale drift...";
157
331
  console.log(chalk.blue(banner));
158
332
 
159
333
  try {
160
- const { config, projectRoot, configPath } = await loadConfigWithMeta(options.config);
161
-
334
+ const { config, projectRoot, configPath } = await loadConfigWithMeta(
335
+ options.config
336
+ );
337
+
162
338
  // Inform user if config was found in a parent directory
163
339
  const cwd = process.cwd();
164
340
  if (projectRoot !== cwd) {
165
- console.log(chalk.gray(`Config found at ${path.relative(cwd, configPath)}`));
341
+ console.log(
342
+ chalk.gray(`Config found at ${path.relative(cwd, configPath)}`)
343
+ );
166
344
  console.log(chalk.gray(`Using project root: ${projectRoot}\n`));
167
345
  }
168
-
346
+
169
347
  if (options.include?.length) {
170
348
  config.include = options.include;
171
349
  }
@@ -184,27 +362,32 @@ export function registerSync(program: Command) {
184
362
  if (options.seedTargetLocales) {
185
363
  config.seedTargetLocales = true;
186
364
  }
187
- if (options.seedValue !== undefined && options.seedValue !== '') {
365
+ if (options.seedValue !== undefined && options.seedValue !== "") {
188
366
  config.sync = config.sync ?? {};
189
367
  config.sync.seedValue = options.seedValue;
190
368
  }
191
369
  const syncer = new Syncer(config, { workspaceRoot: projectRoot });
192
370
  if (interactive) {
193
- await runInteractiveSync(syncer, { ...options, diff: diffEnabled, invalidateCache });
371
+ await runInteractiveSync(syncer, {
372
+ ...options,
373
+ diff: diffEnabled,
374
+ invalidateCache,
375
+ });
194
376
  return;
195
377
  }
196
378
 
197
379
  // If writing with prune, first do a dry-run to check scope
198
380
  const PRUNE_CONFIRMATION_THRESHOLD = 10;
199
381
  let confirmedPrune = options.prune;
200
-
382
+
201
383
  if (options.write && options.prune && !options.yes) {
202
384
  // Quick dry-run to see how many keys would be pruned
203
385
  const dryRunSummary = await syncer.run({
204
386
  write: false,
205
387
  prune: true,
206
388
  validateInterpolations: options.validateInterpolations,
207
- emptyValuePolicy: options.emptyValues === false ? 'fail' : undefined,
389
+ emptyValuePolicy:
390
+ options.emptyValues === false ? "fail" : undefined,
208
391
  assumedKeys: options.assume,
209
392
  diff: false,
210
393
  invalidateCache,
@@ -212,29 +395,45 @@ export function registerSync(program: Command) {
212
395
  });
213
396
 
214
397
  if (dryRunSummary.unusedKeys.length >= PRUNE_CONFIRMATION_THRESHOLD) {
215
- console.log(chalk.yellow(`\n⚠️ About to remove ${dryRunSummary.unusedKeys.length} unused key(s) from locale files.\n`));
216
-
398
+ console.log(
399
+ chalk.yellow(
400
+ `\n⚠️ About to remove ${dryRunSummary.unusedKeys.length} unused key(s) from locale files.\n`
401
+ )
402
+ );
403
+
217
404
  // Show sample of keys to be removed
218
- const sampleKeys = dryRunSummary.unusedKeys.slice(0, 10).map(k => k.key);
405
+ const sampleKeys = dryRunSummary.unusedKeys
406
+ .slice(0, 10)
407
+ .map((k) => k.key);
219
408
  for (const key of sampleKeys) {
220
409
  console.log(chalk.gray(` - ${key}`));
221
410
  }
222
411
  if (dryRunSummary.unusedKeys.length > 10) {
223
- console.log(chalk.gray(` ... and ${dryRunSummary.unusedKeys.length - 10} more`));
412
+ console.log(
413
+ chalk.gray(
414
+ ` ... and ${dryRunSummary.unusedKeys.length - 10} more`
415
+ )
416
+ );
224
417
  }
225
- console.log('');
226
-
227
- const { confirmed } = await inquirer.prompt<{ confirmed: boolean }>([
228
- {
229
- type: 'confirm',
230
- name: 'confirmed',
231
- message: `Remove these ${dryRunSummary.unusedKeys.length} unused keys?`,
232
- default: false,
233
- },
234
- ]);
418
+ console.log("");
419
+
420
+ const { confirmed } = await inquirer.prompt<{ confirmed: boolean }>(
421
+ [
422
+ {
423
+ type: "confirm",
424
+ name: "confirmed",
425
+ message: `Remove these ${dryRunSummary.unusedKeys.length} unused keys?`,
426
+ default: false,
427
+ },
428
+ ]
429
+ );
235
430
 
236
431
  if (!confirmed) {
237
- console.log(chalk.yellow('Prune cancelled. Running with --write only (add missing keys).'));
432
+ console.log(
433
+ chalk.yellow(
434
+ "Prune cancelled. Running with --write only (add missing keys)."
435
+ )
436
+ );
238
437
  confirmedPrune = false;
239
438
  }
240
439
  }
@@ -245,7 +444,7 @@ export function registerSync(program: Command) {
245
444
  prune: confirmedPrune,
246
445
  backup: options.backup,
247
446
  validateInterpolations: options.validateInterpolations,
248
- emptyValuePolicy: options.emptyValues === false ? 'fail' : undefined,
447
+ emptyValuePolicy: options.emptyValues === false ? "fail" : undefined,
249
448
  assumedKeys: options.assume,
250
449
  selection: selectionFromFile,
251
450
  diff: diffRequested,
@@ -253,9 +452,79 @@ export function registerSync(program: Command) {
253
452
  targets: options.target,
254
453
  });
255
454
 
455
+ // If previewing with auto-rename, calculate the rename diffs and include them
456
+ if (
457
+ previewMode &&
458
+ options.autoRenameSuspicious &&
459
+ summary.suspiciousKeys.length > 0
460
+ ) {
461
+ const localesDir = path.resolve(
462
+ process.cwd(),
463
+ config.localesDir ?? "locales"
464
+ );
465
+ const localeStore = new LocaleStore(localesDir, {
466
+ sortKeys: config.locales?.sortKeys ?? "alphabetical",
467
+ });
468
+ const sourceLocale = config.sourceLanguage ?? "en";
469
+ const sourceData = await localeStore.get(sourceLocale);
470
+ const existingKeys = new Set(Object.keys(sourceData));
471
+
472
+ const namingConvention = options.namingConvention ?? "kebab-case";
473
+ const report = generateRenameProposals(summary.suspiciousKeys, {
474
+ existingKeys,
475
+ namingConvention,
476
+ workspaceRoot: projectRoot,
477
+ allowExistingConflicts: true,
478
+ });
479
+
480
+ if (report.safeProposals.length > 0) {
481
+ const mappings = report.safeProposals.map((proposal) => ({
482
+ from: proposal.originalKey,
483
+ to: proposal.proposedKey,
484
+ }));
485
+
486
+ const renamer = new KeyRenamer(config, {
487
+ workspaceRoot: projectRoot,
488
+ });
489
+ // Run rename batch in dry-run mode with diffs
490
+ const batchSummary = await renamer.renameBatch(mappings, {
491
+ write: false,
492
+ diff: true,
493
+ allowConflicts: true,
494
+ });
495
+
496
+ // Merge rename diffs into summary
497
+ summary.renameDiffs = batchSummary.diffs;
498
+
499
+ // Merge locale diffs if any (renamer returns localeDiffs)
500
+ if (
501
+ batchSummary.localeDiffs &&
502
+ batchSummary.localeDiffs.length > 0
503
+ ) {
504
+ summary.localeDiffs = [
505
+ ...(summary.localeDiffs || []),
506
+ ...batchSummary.localeDiffs,
507
+ ];
508
+ // Also update main diffs array if it's used for preview
509
+ summary.diffs = [
510
+ ...(summary.diffs || []),
511
+ ...batchSummary.localeDiffs,
512
+ ];
513
+ }
514
+ }
515
+ }
516
+
256
517
  if (previewMode && options.previewOutput) {
257
- const savedPath = await writePreviewFile('sync', summary, options.previewOutput);
258
- console.log(chalk.green(`Preview written to ${path.relative(process.cwd(), savedPath)}`));
518
+ const savedPath = await writePreviewFile(
519
+ "sync",
520
+ summary,
521
+ options.previewOutput
522
+ );
523
+ console.log(
524
+ chalk.green(
525
+ `Preview written to ${path.relative(process.cwd(), savedPath)}`
526
+ )
527
+ );
259
528
  }
260
529
 
261
530
  // Show backup info if created
@@ -285,25 +554,39 @@ export function registerSync(program: Command) {
285
554
 
286
555
  // Handle --auto-rename-suspicious
287
556
  if (options.autoRenameSuspicious && summary.suspiciousKeys.length > 0) {
288
- await handleAutoRenameSuspicious(summary, options, config, projectRoot);
557
+ await handleAutoRenameSuspicious(
558
+ summary,
559
+ options,
560
+ config,
561
+ projectRoot
562
+ );
289
563
  }
290
564
 
291
565
  // Handle --rewrite-shape
292
- if (options.rewriteShape && (options.rewriteShape === 'flat' || options.rewriteShape === 'nested')) {
566
+ if (
567
+ options.rewriteShape &&
568
+ (options.rewriteShape === "flat" || options.rewriteShape === "nested")
569
+ ) {
293
570
  await handleRewriteShape(options, config);
294
571
  }
295
572
 
296
- const shouldFailPlaceholders = summary.validation.interpolations && summary.placeholderIssues.length > 0;
573
+ const shouldFailPlaceholders =
574
+ summary.validation.interpolations &&
575
+ summary.placeholderIssues.length > 0;
297
576
  const shouldFailEmptyValues =
298
- summary.validation.emptyValuePolicy === 'fail' && summary.emptyValueViolations.length > 0;
577
+ summary.validation.emptyValuePolicy === "fail" &&
578
+ summary.emptyValueViolations.length > 0;
299
579
 
300
580
  // --strict mode: fail on any suspicious patterns
301
581
  if (options.strict) {
302
582
  const hasSuspiciousKeys = summary.suspiciousKeys.length > 0;
303
- const hasDrift = summary.missingKeys.length > 0 || summary.unusedKeys.length > 0;
583
+ const hasDrift =
584
+ summary.missingKeys.length > 0 || summary.unusedKeys.length > 0;
304
585
 
305
586
  if (hasSuspiciousKeys) {
306
- console.error(chalk.red('\n⚠️ Suspicious patterns detected (--strict mode):'));
587
+ console.error(
588
+ chalk.red("\n⚠️ Suspicious patterns detected (--strict mode):")
589
+ );
307
590
  const grouped = new Map<string, string[]>();
308
591
  for (const warning of summary.suspiciousKeys.slice(0, 20)) {
309
592
  const reason = warning.reason;
@@ -316,52 +599,79 @@ export function registerSync(program: Command) {
316
599
  console.error(chalk.yellow(` ${reason}:`));
317
600
  keys.slice(0, 5).forEach((key) => console.error(` • ${key}`));
318
601
  if (keys.length > 5) {
319
- console.error(chalk.gray(` ...and ${keys.length - 5} more.`));
602
+ console.error(
603
+ chalk.gray(` ...and ${keys.length - 5} more.`)
604
+ );
320
605
  }
321
606
  }
322
607
  if (summary.suspiciousKeys.length > 20) {
323
- console.error(chalk.gray(` ...and ${summary.suspiciousKeys.length - 20} more warnings.`));
608
+ console.error(
609
+ chalk.gray(
610
+ ` ...and ${summary.suspiciousKeys.length - 20} more warnings.`
611
+ )
612
+ );
324
613
  }
325
614
  process.exitCode = SYNC_EXIT_CODES.SUSPICIOUS_KEYS;
326
615
  return;
327
616
  }
328
617
 
329
618
  if (shouldFailPlaceholders) {
330
- console.error(chalk.red('\nPlaceholder mismatches detected (--strict mode).'));
619
+ console.error(
620
+ chalk.red("\nPlaceholder mismatches detected (--strict mode).")
621
+ );
331
622
  process.exitCode = SYNC_EXIT_CODES.PLACEHOLDER_MISMATCH;
332
623
  return;
333
624
  }
334
625
 
335
626
  if (shouldFailEmptyValues) {
336
- console.error(chalk.red('\nEmpty locale values detected (--strict mode).'));
627
+ console.error(
628
+ chalk.red("\nEmpty locale values detected (--strict mode).")
629
+ );
337
630
  process.exitCode = SYNC_EXIT_CODES.EMPTY_VALUES;
338
631
  return;
339
632
  }
340
633
 
341
634
  if (hasDrift) {
342
- console.error(chalk.red('\nDrift detected (--strict mode). Run with --write to fix.'));
635
+ console.error(
636
+ chalk.red(
637
+ "\nDrift detected (--strict mode). Run with --write to fix."
638
+ )
639
+ );
343
640
  process.exitCode = SYNC_EXIT_CODES.DRIFT;
344
641
  return;
345
642
  }
346
643
 
347
- console.log(chalk.green('\n✓ No issues detected (--strict mode passed).'));
644
+ console.log(
645
+ chalk.green("\n✓ No issues detected (--strict mode passed).")
646
+ );
348
647
  return;
349
648
  }
350
649
 
351
650
  if (options.check) {
352
- const hasDrift = summary.missingKeys.length || summary.unusedKeys.length;
651
+ const hasDrift =
652
+ summary.missingKeys.length || summary.unusedKeys.length;
353
653
  if (shouldFailPlaceholders) {
354
- console.error(chalk.red('\nPlaceholder mismatches detected. Run with --write to fix.'));
654
+ console.error(
655
+ chalk.red(
656
+ "\nPlaceholder mismatches detected. Run with --write to fix."
657
+ )
658
+ );
355
659
  process.exitCode = SYNC_EXIT_CODES.PLACEHOLDER_MISMATCH;
356
660
  return;
357
661
  }
358
662
  if (shouldFailEmptyValues) {
359
- console.error(chalk.red('\nEmpty locale values detected. Run with --write to fix.'));
663
+ console.error(
664
+ chalk.red(
665
+ "\nEmpty locale values detected. Run with --write to fix."
666
+ )
667
+ );
360
668
  process.exitCode = SYNC_EXIT_CODES.EMPTY_VALUES;
361
669
  return;
362
670
  }
363
671
  if (hasDrift) {
364
- console.error(chalk.red('\nDrift detected. Run with --write to fix.'));
672
+ console.error(
673
+ chalk.red("\nDrift detected. Run with --write to fix.")
674
+ );
365
675
  process.exitCode = SYNC_EXIT_CODES.DRIFT;
366
676
  return;
367
677
  }
@@ -369,117 +679,162 @@ export function registerSync(program: Command) {
369
679
 
370
680
  if (!options.write) {
371
681
  // Show prominent dry-run indicator
372
- console.log(chalk.cyan('\n📋 DRY RUN - No files were modified'));
682
+ console.log(chalk.cyan("\n📋 DRY RUN - No files were modified"));
373
683
  if (summary.missingKeys.length && summary.unusedKeys.length) {
374
- console.log(chalk.yellow('Run again with --write to add missing keys.'));
375
- console.log(chalk.yellow('Run with --write --prune to also remove unused keys.'));
684
+ console.log(
685
+ chalk.yellow("Run again with --write to add missing keys.")
686
+ );
687
+ console.log(
688
+ chalk.yellow(
689
+ "Run with --write --prune to also remove unused keys."
690
+ )
691
+ );
376
692
  } else if (summary.missingKeys.length) {
377
- console.log(chalk.yellow('Run again with --write to add missing keys.'));
693
+ console.log(
694
+ chalk.yellow("Run again with --write to add missing keys.")
695
+ );
378
696
  } else if (summary.unusedKeys.length) {
379
- console.log(chalk.yellow('Unused keys found. Run with --write --prune to remove them.'));
697
+ console.log(
698
+ chalk.yellow(
699
+ "Unused keys found. Run with --write --prune to remove them."
700
+ )
701
+ );
380
702
  }
381
- } else if (options.write && !options.prune && summary.unusedKeys.length) {
382
- console.log(chalk.gray(`\n Note: ${summary.unusedKeys.length} unused key(s) were not removed. Use --prune to remove them.`));
703
+ } else if (
704
+ options.write &&
705
+ !options.prune &&
706
+ summary.unusedKeys.length
707
+ ) {
708
+ console.log(
709
+ chalk.gray(
710
+ `\n Note: ${summary.unusedKeys.length} unused key(s) were not removed. Use --prune to remove them.`
711
+ )
712
+ );
383
713
  }
384
714
  } catch (error) {
385
- console.error(chalk.red('Sync failed:'), (error as Error).message);
715
+ console.error(chalk.red("Sync failed:"), (error as Error).message);
386
716
  process.exitCode = 1;
387
717
  }
388
- });
718
+ })
719
+ );
389
720
  }
390
721
 
391
722
  function printSyncSummary(summary: SyncSummary) {
392
723
  console.log(
393
724
  chalk.green(
394
- `Scanned ${summary.filesScanned} file${summary.filesScanned === 1 ? '' : 's'}; ` +
395
- `${summary.references.length} translation reference${summary.references.length === 1 ? '' : 's'} found.`
725
+ `Scanned ${summary.filesScanned} file${summary.filesScanned === 1 ? "" : "s"}; ` +
726
+ `${summary.references.length} translation reference${summary.references.length === 1 ? "" : "s"} found.`
396
727
  )
397
728
  );
398
729
 
399
730
  if (summary.missingKeys.length) {
400
- console.log(chalk.red('Missing keys:'));
731
+ console.log(chalk.red("Missing keys:"));
401
732
  summary.missingKeys.slice(0, 50).forEach((item) => {
402
733
  const sample = item.references[0];
403
- const location = sample ? `${sample.filePath}:${sample.position.line}` : 'n/a';
404
- console.log(` • ${item.key} (${item.references.length} reference${item.references.length === 1 ? '' : 's'} — e.g., ${location})`);
734
+ const location = sample
735
+ ? `${sample.filePath}:${sample.position.line}`
736
+ : "n/a";
737
+ console.log(
738
+ ` • ${item.key} (${item.references.length} reference${item.references.length === 1 ? "" : "s"} — e.g., ${location})`
739
+ );
405
740
  });
406
741
  if (summary.missingKeys.length > 50) {
407
- console.log(chalk.gray(` ...and ${summary.missingKeys.length - 50} more.`));
742
+ console.log(
743
+ chalk.gray(` ...and ${summary.missingKeys.length - 50} more.`)
744
+ );
408
745
  }
409
746
  } else {
410
- console.log(chalk.green('No missing keys detected.'));
747
+ console.log(chalk.green("No missing keys detected."));
411
748
  }
412
749
 
413
750
  if (summary.unusedKeys.length) {
414
- console.log(chalk.yellow('Unused locale keys:'));
751
+ console.log(chalk.yellow("Unused locale keys:"));
415
752
  summary.unusedKeys.slice(0, 50).forEach((item) => {
416
- console.log(` • ${item.key} (${item.locales.join(', ')})`);
753
+ console.log(` • ${item.key} (${item.locales.join(", ")})`);
417
754
  });
418
755
  if (summary.unusedKeys.length > 50) {
419
- console.log(chalk.gray(` ...and ${summary.unusedKeys.length - 50} more.`));
756
+ console.log(
757
+ chalk.gray(` ...and ${summary.unusedKeys.length - 50} more.`)
758
+ );
420
759
  }
421
760
  } else {
422
- console.log(chalk.green('No unused locale keys detected.'));
761
+ console.log(chalk.green("No unused locale keys detected."));
423
762
  }
424
763
 
425
764
  if (summary.validation.interpolations) {
426
765
  if (summary.placeholderIssues.length) {
427
- console.log(chalk.yellow('Placeholder mismatches:'));
766
+ console.log(chalk.yellow("Placeholder mismatches:"));
428
767
  summary.placeholderIssues.slice(0, 50).forEach((issue) => {
429
- const missing = issue.missing.length ? `missing [${issue.missing.join(', ')}]` : '';
430
- const extra = issue.extra.length ? `extra [${issue.extra.join(', ')}]` : '';
431
- const detail = [missing, extra].filter(Boolean).join('; ');
768
+ const missing = issue.missing.length
769
+ ? `missing [${issue.missing.join(", ")}]`
770
+ : "";
771
+ const extra = issue.extra.length
772
+ ? `extra [${issue.extra.join(", ")}]`
773
+ : "";
774
+ const detail = [missing, extra].filter(Boolean).join("; ");
432
775
  console.log(` • ${issue.key} (${issue.locale}) ${detail}`);
433
776
  });
434
777
  if (summary.placeholderIssues.length > 50) {
435
- console.log(chalk.gray(` ...and ${summary.placeholderIssues.length - 50} more.`));
778
+ console.log(
779
+ chalk.gray(` ...and ${summary.placeholderIssues.length - 50} more.`)
780
+ );
436
781
  }
437
782
  } else {
438
- console.log(chalk.green('No placeholder mismatches detected.'));
783
+ console.log(chalk.green("No placeholder mismatches detected."));
439
784
  }
440
785
  }
441
786
 
442
- if (summary.validation.emptyValuePolicy !== 'ignore') {
787
+ if (summary.validation.emptyValuePolicy !== "ignore") {
443
788
  if (summary.emptyValueViolations.length) {
444
789
  const label =
445
- summary.validation.emptyValuePolicy === 'fail'
446
- ? chalk.red('Empty locale values:')
447
- : chalk.yellow('Empty locale values:');
790
+ summary.validation.emptyValuePolicy === "fail"
791
+ ? chalk.red("Empty locale values:")
792
+ : chalk.yellow("Empty locale values:");
448
793
  console.log(label);
449
794
  summary.emptyValueViolations.slice(0, 50).forEach((violation) => {
450
- console.log(` • ${violation.key} (${violation.locale}) — ${violation.reason}`);
795
+ console.log(
796
+ ` • ${violation.key} (${violation.locale}) — ${violation.reason}`
797
+ );
451
798
  });
452
799
  if (summary.emptyValueViolations.length > 50) {
453
- console.log(chalk.gray(` ...and ${summary.emptyValueViolations.length - 50} more.`));
800
+ console.log(
801
+ chalk.gray(
802
+ ` ...and ${summary.emptyValueViolations.length - 50} more.`
803
+ )
804
+ );
454
805
  }
455
806
  } else {
456
- console.log(chalk.green('No empty locale values detected.'));
807
+ console.log(chalk.green("No empty locale values detected."));
457
808
  }
458
809
  }
459
810
 
460
811
  if (summary.dynamicKeyWarnings.length) {
461
- console.log(chalk.yellow('Dynamic translation keys detected:'));
812
+ console.log(chalk.yellow("Dynamic translation keys detected:"));
462
813
  summary.dynamicKeyWarnings.slice(0, 50).forEach((warning) => {
463
814
  console.log(
464
815
  ` • ${warning.filePath}:${warning.position.line} (${warning.reason}) ${chalk.gray(warning.expression)}`
465
816
  );
466
817
  });
467
818
  if (summary.dynamicKeyWarnings.length > 50) {
468
- console.log(chalk.gray(` ...and ${summary.dynamicKeyWarnings.length - 50} more.`));
819
+ console.log(
820
+ chalk.gray(` ...and ${summary.dynamicKeyWarnings.length - 50} more.`)
821
+ );
469
822
  }
470
823
  if (summary.assumedKeys.length) {
471
- console.log(chalk.blue(`Assumed runtime keys: ${summary.assumedKeys.join(', ')}`));
824
+ console.log(
825
+ chalk.blue(`Assumed runtime keys: ${summary.assumedKeys.join(", ")}`)
826
+ );
472
827
  } else {
473
828
  console.log(
474
829
  chalk.gray(
475
- 'Use --assume key1,key2 to prevent false positives for known runtime-only translation keys.'
830
+ "Use --assume key1,key2 to prevent false positives for known runtime-only translation keys."
476
831
  )
477
832
  );
478
833
  }
479
834
  }
480
835
 
481
836
  if (summary.localeStats.length) {
482
- console.log(chalk.blue('Locale file changes:'));
837
+ console.log(chalk.blue("Locale file changes:"));
483
838
  summary.localeStats.forEach((stat) => {
484
839
  console.log(
485
840
  ` • ${stat.locale}: ${stat.added.length} added, ${stat.updated.length} updated, ${stat.removed.length} removed (total ${stat.totalKeys})`
@@ -488,7 +843,7 @@ function printSyncSummary(summary: SyncSummary) {
488
843
  }
489
844
 
490
845
  if (!summary.write && summary.localePreview.length) {
491
- console.log(chalk.blue('Locale diff preview:'));
846
+ console.log(chalk.blue("Locale diff preview:"));
492
847
  summary.localePreview.forEach((stat) => {
493
848
  console.log(
494
849
  ` • ${stat.locale}: ${stat.add.length} to add, ${stat.remove.length} to remove`
@@ -503,53 +858,85 @@ async function handleAutoRenameSuspicious(
503
858
  config: Awaited<ReturnType<typeof loadConfig>>,
504
859
  projectRoot: string
505
860
  ) {
506
- console.log(chalk.blue('\n📝 Auto-rename suspicious keys analysis:'));
861
+ console.log(chalk.blue("\n📝 Auto-rename suspicious keys analysis:"));
507
862
 
508
863
  // Get existing keys from locale data to check for conflicts
509
- const localesDir = path.resolve(process.cwd(), config.localesDir ?? 'locales');
864
+ const localesDir = path.resolve(
865
+ process.cwd(),
866
+ config.localesDir ?? "locales"
867
+ );
510
868
  const localeStore = new LocaleStore(localesDir, {
511
- sortKeys: config.locales?.sortKeys ?? 'alphabetical',
869
+ sortKeys: config.locales?.sortKeys ?? "alphabetical",
512
870
  });
513
- const sourceLocale = config.sourceLanguage ?? 'en';
871
+ const sourceLocale = config.sourceLanguage ?? "en";
514
872
  const sourceData = await localeStore.get(sourceLocale);
515
873
  const existingKeys = new Set(Object.keys(sourceData));
516
874
 
517
875
  // Generate rename proposals
518
- const namingConvention = options.namingConvention ?? 'kebab-case';
876
+ const namingConvention = options.namingConvention ?? "kebab-case";
519
877
  const report = generateRenameProposals(summary.suspiciousKeys, {
520
878
  existingKeys,
521
879
  namingConvention,
880
+ allowExistingConflicts: true,
522
881
  });
523
882
 
524
883
  // Print summary
525
884
  console.log(` Found ${report.totalSuspicious} suspicious key(s)`);
526
885
 
527
886
  if (report.safeProposals.length > 0) {
528
- console.log(chalk.green(`\n ✓ Safe rename proposals (${report.safeProposals.length}):`));
887
+ console.log(
888
+ chalk.green(
889
+ `\n ✓ Safe rename proposals (${report.safeProposals.length}):`
890
+ )
891
+ );
529
892
  const toShow = report.safeProposals.slice(0, 10);
530
893
  for (const proposal of toShow) {
531
- console.log(chalk.gray(` "${proposal.originalKey}" → "${proposal.proposedKey}"`));
532
- console.log(chalk.gray(` (${proposal.reason}) in ${proposal.filePath}:${proposal.position.line}`));
894
+ console.log(
895
+ chalk.gray(` "${proposal.originalKey}" "${proposal.proposedKey}"`)
896
+ );
897
+ console.log(
898
+ chalk.gray(
899
+ ` (${proposal.reason}) in ${proposal.filePath}:${proposal.position.line}`
900
+ )
901
+ );
533
902
  }
534
903
  if (report.safeProposals.length > 10) {
535
- console.log(chalk.gray(` ...and ${report.safeProposals.length - 10} more`));
904
+ console.log(
905
+ chalk.gray(` ...and ${report.safeProposals.length - 10} more`)
906
+ );
536
907
  }
537
908
  }
538
909
 
539
910
  if (report.conflictProposals.length > 0) {
540
- console.log(chalk.yellow(`\n ⚠️ Conflicting proposals (${report.conflictProposals.length}):`));
911
+ console.log(
912
+ chalk.yellow(
913
+ `\n ⚠️ Conflicting proposals (${report.conflictProposals.length}):`
914
+ )
915
+ );
541
916
  const toShow = report.conflictProposals.slice(0, 5);
542
917
  for (const proposal of toShow) {
543
- console.log(chalk.yellow(` "${proposal.originalKey}" → "${proposal.proposedKey}"`));
544
- console.log(chalk.gray(` Conflicts with: ${proposal.conflictsWith}`));
918
+ console.log(
919
+ chalk.yellow(
920
+ ` "${proposal.originalKey}" → "${proposal.proposedKey}"`
921
+ )
922
+ );
923
+ console.log(
924
+ chalk.gray(` Conflicts with: ${proposal.conflictsWith}`)
925
+ );
545
926
  }
546
927
  if (report.conflictProposals.length > 5) {
547
- console.log(chalk.gray(` ...and ${report.conflictProposals.length - 5} more`));
928
+ console.log(
929
+ chalk.gray(` ...and ${report.conflictProposals.length - 5} more`)
930
+ );
548
931
  }
549
932
  }
550
933
 
551
934
  if (report.skippedKeys.length > 0) {
552
- console.log(chalk.gray(`\n Skipped ${report.skippedKeys.length} key(s) (already normalized or no change needed)`));
935
+ console.log(
936
+ chalk.gray(
937
+ `\n Skipped ${report.skippedKeys.length} key(s) (already normalized or no change needed)`
938
+ )
939
+ );
553
940
  }
554
941
 
555
942
  // Write mapping file if requested
@@ -557,34 +944,53 @@ async function handleAutoRenameSuspicious(
557
944
 
558
945
  if (options.renameMapFile && hasMappings) {
559
946
  const outputPath = path.resolve(process.cwd(), options.renameMapFile);
560
- const isJsonFormat = outputPath.endsWith('.json');
947
+ const isJsonFormat = outputPath.endsWith(".json");
561
948
  const content = createRenameMappingFile(report.renameMapping, {
562
949
  includeComments: !isJsonFormat,
563
950
  });
564
951
  await fs.mkdir(path.dirname(outputPath), { recursive: true });
565
- await fs.writeFile(outputPath, content, 'utf8');
952
+ await fs.writeFile(outputPath, content, "utf8");
566
953
  console.log(chalk.green(`\n ✓ Rename mapping written to ${outputPath}`));
567
- console.log(chalk.gray(' Apply with: npx i18nsmith rename-keys --map ' + options.renameMapFile + ' --write'));
954
+ console.log(
955
+ chalk.gray(
956
+ " Apply with: npx i18nsmith rename-keys --map " +
957
+ options.renameMapFile +
958
+ " --write"
959
+ )
960
+ );
568
961
  }
569
962
 
570
963
  if (options.write) {
571
964
  if (report.safeProposals.length === 0) {
572
- console.log(chalk.yellow('\n No safe rename proposals to apply.'));
965
+ console.log(chalk.yellow("\n No safe rename proposals to apply."));
573
966
  } else {
574
- console.log(chalk.blue('\n✍️ Applying safe rename proposals (source + locales)...'));
967
+ console.log(
968
+ chalk.blue("\n✍️ Applying safe rename proposals (source + locales)...")
969
+ );
575
970
  const mappings = report.safeProposals.map((proposal) => ({
576
971
  from: proposal.originalKey,
577
972
  to: proposal.proposedKey,
578
973
  }));
579
974
 
580
975
  const renamer = new KeyRenamer(config, { workspaceRoot: projectRoot });
581
- const applySummary = await renamer.renameBatch(mappings, { write: true, diff: Boolean(options.diff) });
976
+ const applySummary = await renamer.renameBatch(mappings, {
977
+ write: true,
978
+ diff: Boolean(options.diff),
979
+ allowConflicts: true,
980
+ });
582
981
  printRenameBatchSummary(applySummary);
583
982
 
584
983
  if (!options.renameMapFile && hasMappings) {
585
- const defaultMapPath = path.resolve(projectRoot, '.i18nsmith', 'auto-rename-map.json');
984
+ const defaultMapPath = path.resolve(
985
+ projectRoot,
986
+ ".i18nsmith",
987
+ "auto-rename-map.json"
988
+ );
586
989
  await fs.mkdir(path.dirname(defaultMapPath), { recursive: true });
587
- await fs.writeFile(defaultMapPath, JSON.stringify(report.renameMapping, null, 2));
990
+ await fs.writeFile(
991
+ defaultMapPath,
992
+ JSON.stringify(report.renameMapping, null, 2)
993
+ );
588
994
  console.log(
589
995
  chalk.gray(
590
996
  `\n Saved rename mapping to ${path.relative(process.cwd(), defaultMapPath)} (set --rename-map-file to customize)`
@@ -593,8 +999,14 @@ async function handleAutoRenameSuspicious(
593
999
  }
594
1000
  }
595
1001
  } else if (hasMappings && !options.renameMapFile) {
596
- console.log(chalk.gray('\n Use --rename-map-file <path> to export mappings for later application.'));
597
- console.log(chalk.gray(' Run with --write to apply safe proposals automatically.'));
1002
+ console.log(
1003
+ chalk.gray(
1004
+ "\n Use --rename-map-file <path> to export mappings for later application."
1005
+ )
1006
+ );
1007
+ console.log(
1008
+ chalk.gray(" Run with --write to apply safe proposals automatically.")
1009
+ );
598
1010
  }
599
1011
  }
600
1012
 
@@ -602,19 +1014,24 @@ async function handleRewriteShape(
602
1014
  options: SyncCommandOptions,
603
1015
  config: Awaited<ReturnType<typeof loadConfig>>
604
1016
  ) {
605
- const targetFormat = options.rewriteShape as 'flat' | 'nested';
606
- const delimiter = options.shapeDelimiter ?? '.';
1017
+ const targetFormat = options.rewriteShape as "flat" | "nested";
1018
+ const delimiter = options.shapeDelimiter ?? ".";
607
1019
 
608
- console.log(chalk.blue(`\n🔄 Rewriting locale files to ${targetFormat} format...`));
1020
+ console.log(
1021
+ chalk.blue(`\n🔄 Rewriting locale files to ${targetFormat} format...`)
1022
+ );
609
1023
 
610
- const localesDir = path.resolve(process.cwd(), config.localesDir ?? 'locales');
1024
+ const localesDir = path.resolve(
1025
+ process.cwd(),
1026
+ config.localesDir ?? "locales"
1027
+ );
611
1028
  const localeStore = new LocaleStore(localesDir, {
612
1029
  delimiter,
613
- sortKeys: config.locales?.sortKeys ?? 'alphabetical',
1030
+ sortKeys: config.locales?.sortKeys ?? "alphabetical",
614
1031
  });
615
1032
 
616
1033
  // Load all configured locales
617
- const sourceLocale = config.sourceLanguage ?? 'en';
1034
+ const sourceLocale = config.sourceLanguage ?? "en";
618
1035
  const targetLocales = config.targetLanguages ?? [];
619
1036
  const allLocales = [sourceLocale, ...targetLocales];
620
1037
 
@@ -626,11 +1043,15 @@ async function handleRewriteShape(
626
1043
  const stats = await localeStore.rewriteShape(targetFormat, { delimiter });
627
1044
 
628
1045
  if (stats.length === 0) {
629
- console.log(chalk.yellow(' No locale files found to rewrite.'));
1046
+ console.log(chalk.yellow(" No locale files found to rewrite."));
630
1047
  return;
631
1048
  }
632
1049
 
633
- console.log(chalk.green(` ✓ Rewrote ${stats.length} locale file(s) to ${targetFormat} format:`));
1050
+ console.log(
1051
+ chalk.green(
1052
+ ` ✓ Rewrote ${stats.length} locale file(s) to ${targetFormat} format:`
1053
+ )
1054
+ );
634
1055
  for (const stat of stats) {
635
1056
  console.log(chalk.gray(` • ${stat.locale}: ${stat.totalKeys} keys`));
636
1057
  }
@@ -642,7 +1063,7 @@ async function runInteractiveSync(syncer: Syncer, options: SyncCommandOptions) {
642
1063
  const baseline = await syncer.run({
643
1064
  write: false,
644
1065
  validateInterpolations: options.validateInterpolations,
645
- emptyValuePolicy: options.emptyValues === false ? 'fail' : undefined,
1066
+ emptyValuePolicy: options.emptyValues === false ? "fail" : undefined,
646
1067
  assumedKeys: options.assume,
647
1068
  diff: diffEnabled,
648
1069
  invalidateCache,
@@ -658,19 +1079,19 @@ async function runInteractiveSync(syncer: Syncer, options: SyncCommandOptions) {
658
1079
  }
659
1080
 
660
1081
  if (!baseline.missingKeys.length && !baseline.unusedKeys.length) {
661
- console.log(chalk.green('No drift detected. Nothing to apply.'));
1082
+ console.log(chalk.green("No drift detected. Nothing to apply."));
662
1083
  return;
663
1084
  }
664
1085
 
665
1086
  const prompts: CheckboxQuestion[] = [];
666
1087
  if (baseline.missingKeys.length) {
667
1088
  prompts.push({
668
- type: 'checkbox',
669
- name: 'missing',
670
- message: 'Select missing keys to add',
1089
+ type: "checkbox",
1090
+ name: "missing",
1091
+ message: "Select missing keys to add",
671
1092
  pageSize: 15,
672
1093
  choices: baseline.missingKeys.map((item) => ({
673
- name: `${item.key} (${item.references.length} reference${item.references.length === 1 ? '' : 's'})`,
1094
+ name: `${item.key} (${item.references.length} reference${item.references.length === 1 ? "" : "s"})`,
674
1095
  value: item.key,
675
1096
  checked: true,
676
1097
  })),
@@ -679,12 +1100,12 @@ async function runInteractiveSync(syncer: Syncer, options: SyncCommandOptions) {
679
1100
 
680
1101
  if (baseline.unusedKeys.length) {
681
1102
  prompts.push({
682
- type: 'checkbox',
683
- name: 'unused',
684
- message: 'Select unused keys to prune',
1103
+ type: "checkbox",
1104
+ name: "unused",
1105
+ message: "Select unused keys to prune",
685
1106
  pageSize: 15,
686
1107
  choices: baseline.unusedKeys.map((item) => ({
687
- name: `${item.key} (${item.locales.join(', ')})`,
1108
+ name: `${item.key} (${item.locales.join(", ")})`,
688
1109
  value: item.key,
689
1110
  checked: true,
690
1111
  })),
@@ -692,32 +1113,36 @@ async function runInteractiveSync(syncer: Syncer, options: SyncCommandOptions) {
692
1113
  }
693
1114
 
694
1115
  const answers = prompts.length ? await inquirer.prompt(prompts) : {};
695
- const selectedMissing: string[] = (answers as { missing?: string[] }).missing ?? [];
696
- const selectedUnused: string[] = (answers as { unused?: string[] }).unused ?? [];
1116
+ const selectedMissing: string[] =
1117
+ (answers as { missing?: string[] }).missing ?? [];
1118
+ const selectedUnused: string[] =
1119
+ (answers as { unused?: string[] }).unused ?? [];
697
1120
 
698
1121
  if (!selectedMissing.length && !selectedUnused.length) {
699
- console.log(chalk.yellow('No changes selected. Run again later if needed.'));
1122
+ console.log(
1123
+ chalk.yellow("No changes selected. Run again later if needed.")
1124
+ );
700
1125
  return;
701
1126
  }
702
1127
 
703
1128
  const confirmation = await inquirer.prompt<{ proceed: boolean }>([
704
1129
  {
705
- type: 'confirm',
706
- name: 'proceed',
1130
+ type: "confirm",
1131
+ name: "proceed",
707
1132
  default: true,
708
- message: `Apply ${selectedMissing.length} addition${selectedMissing.length === 1 ? '' : 's'} and ${selectedUnused.length} removal${selectedUnused.length === 1 ? '' : 's'}?`,
1133
+ message: `Apply ${selectedMissing.length} addition${selectedMissing.length === 1 ? "" : "s"} and ${selectedUnused.length} removal${selectedUnused.length === 1 ? "" : "s"}?`,
709
1134
  },
710
1135
  ]);
711
1136
 
712
1137
  if (!confirmation.proceed) {
713
- console.log(chalk.yellow('Aborted. No changes written.'));
1138
+ console.log(chalk.yellow("Aborted. No changes written."));
714
1139
  return;
715
1140
  }
716
1141
 
717
1142
  const writeSummary = await syncer.run({
718
1143
  write: true,
719
1144
  validateInterpolations: options.validateInterpolations,
720
- emptyValuePolicy: options.emptyValues === false ? 'fail' : undefined,
1145
+ emptyValuePolicy: options.emptyValues === false ? "fail" : undefined,
721
1146
  assumedKeys: options.assume,
722
1147
  selection: {
723
1148
  missing: selectedMissing,
@@ -737,13 +1162,17 @@ async function runInteractiveSync(syncer: Syncer, options: SyncCommandOptions) {
737
1162
  }
738
1163
 
739
1164
  async function loadSelectionFile(filePath: string): Promise<SyncSelection> {
740
- const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(process.cwd(), filePath);
1165
+ const resolvedPath = path.isAbsolute(filePath)
1166
+ ? filePath
1167
+ : path.resolve(process.cwd(), filePath);
741
1168
  let raw: string;
742
1169
  try {
743
- raw = await fs.readFile(resolvedPath, 'utf8');
1170
+ raw = await fs.readFile(resolvedPath, "utf8");
744
1171
  } catch (error) {
745
1172
  const message = error instanceof Error ? error.message : String(error);
746
- throw new Error(`Unable to read selection file at ${resolvedPath}: ${message}`);
1173
+ throw new Error(
1174
+ `Unable to read selection file at ${resolvedPath}: ${message}`
1175
+ );
747
1176
  }
748
1177
 
749
1178
  let parsed: unknown;
@@ -751,7 +1180,9 @@ async function loadSelectionFile(filePath: string): Promise<SyncSelection> {
751
1180
  parsed = JSON.parse(raw);
752
1181
  } catch (error) {
753
1182
  const message = error instanceof Error ? error.message : String(error);
754
- throw new Error(`Selection file ${resolvedPath} contains invalid JSON: ${message}`);
1183
+ throw new Error(
1184
+ `Selection file ${resolvedPath} contains invalid JSON: ${message}`
1185
+ );
755
1186
  }
756
1187
 
757
1188
  const selection: SyncSelection = {};
@@ -773,7 +1204,9 @@ async function loadSelectionFile(filePath: string): Promise<SyncSelection> {
773
1204
  }
774
1205
 
775
1206
  if (!selection.missing?.length && !selection.unused?.length) {
776
- throw new Error(`Selection file ${resolvedPath} must include at least one "missing" or "unused" entry.`);
1207
+ throw new Error(
1208
+ `Selection file ${resolvedPath} must include at least one "missing" or "unused" entry.`
1209
+ );
777
1210
  }
778
1211
 
779
1212
  return selection;
@@ -782,16 +1215,16 @@ async function loadSelectionFile(filePath: string): Promise<SyncSelection> {
782
1215
  function printRenameBatchSummary(summary: KeyRenameBatchSummary) {
783
1216
  console.log(
784
1217
  chalk.green(
785
- `Updated ${summary.occurrences} occurrence${summary.occurrences === 1 ? '' : 's'} across ${summary.filesUpdated.length} file${summary.filesUpdated.length === 1 ? '' : 's'}.`
1218
+ `Updated ${summary.occurrences} occurrence${summary.occurrences === 1 ? "" : "s"} across ${summary.filesUpdated.length} file${summary.filesUpdated.length === 1 ? "" : "s"}.`
786
1219
  )
787
1220
  );
788
1221
 
789
1222
  if (summary.mappingSummaries.length === 0) {
790
- console.log(chalk.yellow('No mappings were applied.'));
1223
+ console.log(chalk.yellow("No mappings were applied."));
791
1224
  } else {
792
- console.log(chalk.blue('Mappings:'));
1225
+ console.log(chalk.blue("Mappings:"));
793
1226
  summary.mappingSummaries.slice(0, 50).forEach((mapping) => {
794
- const refLabel = `${mapping.occurrences} reference${mapping.occurrences === 1 ? '' : 's'}`;
1227
+ const refLabel = `${mapping.occurrences} reference${mapping.occurrences === 1 ? "" : "s"}`;
795
1228
  console.log(` • ${mapping.from} → ${mapping.to} (${refLabel})`);
796
1229
 
797
1230
  const duplicates = mapping.localePreview
@@ -800,27 +1233,31 @@ function printRenameBatchSummary(summary: KeyRenameBatchSummary) {
800
1233
  const missing = mapping.missingLocales;
801
1234
 
802
1235
  const annotations = [
803
- missing.length ? `missing locales: ${missing.join(', ')}` : null,
804
- duplicates.length ? `target already exists in: ${duplicates.join(', ')}` : null,
1236
+ missing.length ? `missing locales: ${missing.join(", ")}` : null,
1237
+ duplicates.length
1238
+ ? `target already exists in: ${duplicates.join(", ")}`
1239
+ : null,
805
1240
  ].filter(Boolean);
806
1241
 
807
1242
  if (annotations.length) {
808
- console.log(chalk.gray(` ${annotations.join(' · ')}`));
1243
+ console.log(chalk.gray(` ${annotations.join(" · ")}`));
809
1244
  }
810
1245
  });
811
1246
 
812
1247
  if (summary.mappingSummaries.length > 50) {
813
- console.log(chalk.gray(` ...and ${summary.mappingSummaries.length - 50} more.`));
1248
+ console.log(
1249
+ chalk.gray(` ...and ${summary.mappingSummaries.length - 50} more.`)
1250
+ );
814
1251
  }
815
1252
  }
816
1253
 
817
1254
  if (summary.filesUpdated.length) {
818
- console.log(chalk.blue('Files updated:'));
1255
+ console.log(chalk.blue("Files updated:"));
819
1256
  summary.filesUpdated.forEach((file) => console.log(` • ${file}`));
820
1257
  }
821
1258
 
822
1259
  if (summary.localeStats.length) {
823
- console.log(chalk.blue('Locale updates:'));
1260
+ console.log(chalk.blue("Locale updates:"));
824
1261
  summary.localeStats.forEach((stat) => {
825
1262
  console.log(
826
1263
  ` • ${stat.locale}: ${stat.added.length} added, ${stat.updated.length} updated, ${stat.removed.length} removed (total ${stat.totalKeys})`