i18nsmith 0.4.3 → 0.6.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 (42) hide show
  1. package/README.md +126 -0
  2. package/build.mjs +5 -0
  3. package/dist/commands/backup.d.ts.map +1 -1
  4. package/dist/commands/check.d.ts.map +1 -1
  5. package/dist/commands/config.d.ts.map +1 -1
  6. package/dist/commands/coverage.d.ts.map +1 -1
  7. package/dist/commands/detect.d.ts.map +1 -1
  8. package/dist/commands/diagnose.d.ts.map +1 -1
  9. package/dist/commands/init.d.ts.map +1 -1
  10. package/dist/commands/rename.d.ts.map +1 -1
  11. package/dist/commands/review.d.ts.map +1 -1
  12. package/dist/commands/scaffold-adapter.d.ts.map +1 -1
  13. package/dist/commands/scan.d.ts.map +1 -1
  14. package/dist/commands/sync.d.ts.map +1 -1
  15. package/dist/commands/transform.d.ts.map +1 -1
  16. package/dist/index.cjs +13069 -6794
  17. package/dist/services/index.d.ts +12 -0
  18. package/dist/services/index.d.ts.map +1 -0
  19. package/dist/services/transform-service.d.ts +64 -0
  20. package/dist/services/transform-service.d.ts.map +1 -0
  21. package/dist/utils/bootstrap.d.ts +83 -0
  22. package/dist/utils/bootstrap.d.ts.map +1 -0
  23. package/package.json +1 -1
  24. package/src/commands/backup.ts +28 -10
  25. package/src/commands/check.ts +16 -4
  26. package/src/commands/config.ts +126 -0
  27. package/src/commands/coverage.ts +11 -4
  28. package/src/commands/detect.ts +11 -4
  29. package/src/commands/diagnose.ts +12 -2
  30. package/src/commands/init.test.ts +80 -0
  31. package/src/commands/init.ts +92 -49
  32. package/src/commands/rename.ts +24 -5
  33. package/src/commands/review.ts +10 -3
  34. package/src/commands/scaffold-adapter.ts +11 -2
  35. package/src/commands/scan.ts +20 -7
  36. package/src/commands/sync.ts +91 -23
  37. package/src/commands/transform.ts +8 -1
  38. package/src/index.ts +1 -1
  39. package/src/integration.test.ts +145 -12
  40. package/src/services/index.ts +12 -0
  41. package/src/services/transform-service.ts +203 -0
  42. package/src/utils/bootstrap.ts +221 -0
@@ -4,9 +4,9 @@ import inquirer, { type CheckboxQuestion } from "inquirer";
4
4
  import { promises as fs } from "fs";
5
5
  import path from "path";
6
6
  import {
7
+ // Legacy imports - kept for complex interactive flows
8
+ // TODO: Migrate interactive sync to use ISyncService when it supports passing syncer instance
7
9
  Syncer,
8
- KeyRenamer,
9
- LocaleStore,
10
10
  loadConfig,
11
11
  loadConfigWithMeta,
12
12
  generateRenameProposals,
@@ -15,6 +15,7 @@ import {
15
15
  type KeyRenameBatchSummary,
16
16
  type SyncSelection,
17
17
  } from "@i18nsmith/core";
18
+ import { getServiceContainer } from "../utils/bootstrap.js";
18
19
  import {
19
20
  printLocaleDiffs,
20
21
  writeLocaleDiffPatches,
@@ -22,7 +23,7 @@ import {
22
23
  import { applyPreviewFile, writePreviewFile } from "../utils/preview.js";
23
24
  import { SYNC_EXIT_CODES } from "../utils/exit-codes.js";
24
25
  import { runCheck } from "./check.js";
25
- import { withErrorHandling } from "../utils/errors.js";
26
+ import { withErrorHandling, CliError } from "../utils/errors.js";
26
27
 
27
28
  interface SyncCommandOptions {
28
29
  config?: string;
@@ -48,7 +49,7 @@ interface SyncCommandOptions {
48
49
  invalidateCache?: boolean;
49
50
  autoRenameSuspicious?: boolean;
50
51
  renameMapFile?: string;
51
- namingConvention?: "kebab-case" | "camelCase" | "snake_case";
52
+ namingConvention?: "kebab-case" | "camelCase" | "snake_case" | "auto";
52
53
  rewriteShape?: "flat" | "nested";
53
54
  shapeDelimiter?: string;
54
55
  seedTargetLocales?: boolean;
@@ -193,7 +194,7 @@ export function registerSync(program: Command) {
193
194
  )
194
195
  .option(
195
196
  "--naming-convention <convention>",
196
- "Naming convention for auto-rename (kebab-case, camelCase, snake_case)",
197
+ "Naming convention for auto-rename (kebab-case, camelCase, snake_case, auto)",
197
198
  "kebab-case"
198
199
  )
199
200
  .option(
@@ -328,7 +329,14 @@ export function registerSync(program: Command) {
328
329
  : writeEnabled
329
330
  ? "Syncing locale files..."
330
331
  : "Checking locale drift...";
331
- console.log(chalk.blue(banner));
332
+ // When JSON output is requested, avoid human-readable preamble on stdout
333
+ // so callers can reliably parse the JSON summary. Send banner to stderr
334
+ // when --json is used.
335
+ if (options.json) {
336
+ console.error(chalk.blue(banner));
337
+ } else {
338
+ console.log(chalk.blue(banner));
339
+ }
332
340
 
333
341
  try {
334
342
  const { config, projectRoot, configPath } = await loadConfigWithMeta(
@@ -467,11 +475,16 @@ export function registerSync(program: Command) {
467
475
  process.cwd(),
468
476
  config.localesDir ?? "locales"
469
477
  );
470
- const localeStore = new LocaleStore(localesDir, {
478
+ const localeContainer = getServiceContainer({ workspaceRoot: projectRoot });
479
+ const localeStore = localeContainer.localeStoreFactory.create(localesDir, {
471
480
  sortKeys: config.locales?.sortKeys ?? "alphabetical",
472
481
  });
473
482
  const sourceLocale = config.sourceLanguage ?? "en";
474
- const sourceData = await localeStore.get(sourceLocale);
483
+ const sourceResult = await localeStore.get(sourceLocale);
484
+ if (!sourceResult.success) {
485
+ throw new CliError(`Failed to read source locale: ${sourceResult.error.message}`);
486
+ }
487
+ const sourceData = sourceResult.data;
475
488
  const existingKeys = new Set(Object.keys(sourceData));
476
489
 
477
490
  const namingConvention = options.namingConvention ?? "kebab-case";
@@ -480,6 +493,8 @@ export function registerSync(program: Command) {
480
493
  namingConvention,
481
494
  workspaceRoot: projectRoot,
482
495
  allowExistingConflicts: true,
496
+ allExistingKeys: Object.keys(sourceData), // Pass all existing keys for convention detection
497
+ preserveExistingConvention: true, // Respect existing project conventions
483
498
  });
484
499
 
485
500
  if (report.safeProposals.length > 0) {
@@ -488,15 +503,17 @@ export function registerSync(program: Command) {
488
503
  to: proposal.proposedKey,
489
504
  }));
490
505
 
491
- const renamer = new KeyRenamer(config, {
492
- workspaceRoot: projectRoot,
493
- });
506
+ const renameContainer = getServiceContainer({ workspaceRoot: projectRoot });
494
507
  // Run rename batch in dry-run mode with diffs
495
- const batchSummary = await renamer.renameBatch(mappings, {
508
+ const renameResult = await renameContainer.keyRenamer.renameBatch(config, mappings, {
496
509
  write: false,
497
510
  diff: true,
498
511
  allowConflicts: true,
499
512
  });
513
+ if (!renameResult.success) {
514
+ throw new CliError(`Rename analysis failed: ${renameResult.error.message}`);
515
+ }
516
+ const batchSummary = renameResult.data;
500
517
 
501
518
  // Merge rename diffs into summary
502
519
  summary.renameDiffs = batchSummary.diffs;
@@ -575,7 +592,7 @@ export function registerSync(program: Command) {
575
592
  options.rewriteShape &&
576
593
  (options.rewriteShape === "flat" || options.rewriteShape === "nested")
577
594
  ) {
578
- await handleRewriteShape(options, config);
595
+ await handleRewriteShape(options, config, projectRoot);
579
596
  }
580
597
 
581
598
  const shouldFailPlaceholders =
@@ -769,6 +786,18 @@ function printSyncSummary(summary: SyncSummary) {
769
786
  console.log(chalk.green("No unused locale keys detected."));
770
787
  }
771
788
 
789
+ if (summary.untranslatedKeys.length) {
790
+ console.log(chalk.blue("Untranslated keys (protected from pruning):"));
791
+ summary.untranslatedKeys.slice(0, 50).forEach((item) => {
792
+ console.log(` • ${item.key} (${item.locales.join(", ")})`);
793
+ });
794
+ if (summary.untranslatedKeys.length > 50) {
795
+ console.log(
796
+ chalk.gray(` ...and ${summary.untranslatedKeys.length - 50} more.`)
797
+ );
798
+ }
799
+ }
800
+
772
801
  if (summary.validation.interpolations) {
773
802
  if (summary.placeholderIssues.length) {
774
803
  console.log(chalk.yellow("Placeholder mismatches:"));
@@ -873,18 +902,43 @@ async function handleAutoRenameSuspicious(
873
902
  process.cwd(),
874
903
  config.localesDir ?? "locales"
875
904
  );
876
- const localeStore = new LocaleStore(localesDir, {
905
+ const container = getServiceContainer({ workspaceRoot: projectRoot });
906
+ const localeStore = container.localeStoreFactory.create(localesDir, {
877
907
  sortKeys: config.locales?.sortKeys ?? "alphabetical",
878
908
  });
879
909
  const sourceLocale = config.sourceLanguage ?? "en";
880
- const sourceData = await localeStore.get(sourceLocale);
910
+ const sourceResult = await localeStore.get(sourceLocale);
911
+ if (!sourceResult.success) {
912
+ throw new CliError(`Failed to read source locale: ${sourceResult.error.message}`);
913
+ }
914
+ const sourceData = sourceResult.data;
881
915
  const existingKeys = new Set(Object.keys(sourceData));
882
916
 
883
- // Generate rename proposals
917
+ // For auto-detection, collect all existing keys from all locales
918
+ let allExistingKeys: string[] | undefined;
884
919
  const namingConvention = options.namingConvention ?? "kebab-case";
920
+ if (namingConvention === "auto") {
921
+ const storedLocalesResult = await localeStore.getStoredLocales();
922
+ if (storedLocalesResult.success) {
923
+ const allKeys = new Set<string>();
924
+ for (const locale of storedLocalesResult.data) {
925
+ const localeResult = await localeStore.get(locale);
926
+ if (localeResult.success) {
927
+ Object.keys(localeResult.data).forEach(key => allKeys.add(key));
928
+ }
929
+ }
930
+ allExistingKeys = Array.from(allKeys);
931
+ console.log(` Auto-detected naming convention from ${allKeys.size} existing keys`);
932
+ } else {
933
+ console.warn(chalk.yellow(" Warning: Could not load all locales for convention detection, falling back to kebab-case"));
934
+ }
935
+ }
936
+
937
+ // Generate rename proposals
885
938
  const report = generateRenameProposals(summary.suspiciousKeys, {
886
939
  existingKeys,
887
940
  namingConvention,
941
+ allExistingKeys,
888
942
  allowExistingConflicts: true,
889
943
  });
890
944
 
@@ -980,12 +1034,16 @@ async function handleAutoRenameSuspicious(
980
1034
  to: proposal.proposedKey,
981
1035
  }));
982
1036
 
983
- const renamer = new KeyRenamer(config, { workspaceRoot: projectRoot });
984
- const applySummary = await renamer.renameBatch(mappings, {
1037
+ const renameContainer = getServiceContainer({ workspaceRoot: projectRoot });
1038
+ const renameResult = await renameContainer.keyRenamer.renameBatch(config, mappings, {
985
1039
  write: true,
986
1040
  diff: Boolean(options.diff),
987
1041
  allowConflicts: true,
988
1042
  });
1043
+ if (!renameResult.success) {
1044
+ throw new CliError(`Batch rename failed: ${renameResult.error.message}`);
1045
+ }
1046
+ const applySummary = renameResult.data;
989
1047
  printRenameBatchSummary(applySummary);
990
1048
 
991
1049
  if (!options.renameMapFile && hasMappings) {
@@ -1024,12 +1082,16 @@ async function handleAutoRenameSuspicious(
1024
1082
  to: proposal.proposedKey,
1025
1083
  }));
1026
1084
 
1027
- const renamer = new KeyRenamer(config, { workspaceRoot: projectRoot });
1028
- const diffSummary = await renamer.renameBatch(mappings, {
1085
+ const renameContainer = getServiceContainer({ workspaceRoot: projectRoot });
1086
+ const diffResult = await renameContainer.keyRenamer.renameBatch(config, mappings, {
1029
1087
  write: false,
1030
1088
  diff: true,
1031
1089
  allowConflicts: true,
1032
1090
  });
1091
+ if (!diffResult.success) {
1092
+ throw new CliError(`Diff generation failed: ${diffResult.error.message}`);
1093
+ }
1094
+ const diffSummary = diffResult.data;
1033
1095
 
1034
1096
  return diffSummary.diffs;
1035
1097
  }
@@ -1037,7 +1099,8 @@ async function handleAutoRenameSuspicious(
1037
1099
 
1038
1100
  async function handleRewriteShape(
1039
1101
  options: SyncCommandOptions,
1040
- config: Awaited<ReturnType<typeof loadConfig>>
1102
+ config: Awaited<ReturnType<typeof loadConfig>>,
1103
+ projectRoot: string
1041
1104
  ) {
1042
1105
  const targetFormat = options.rewriteShape as "flat" | "nested";
1043
1106
  const delimiter = options.shapeDelimiter ?? ".";
@@ -1050,7 +1113,8 @@ async function handleRewriteShape(
1050
1113
  process.cwd(),
1051
1114
  config.localesDir ?? "locales"
1052
1115
  );
1053
- const localeStore = new LocaleStore(localesDir, {
1116
+ const container = getServiceContainer({ workspaceRoot: projectRoot });
1117
+ const localeStore = container.localeStoreFactory.create(localesDir, {
1054
1118
  delimiter,
1055
1119
  sortKeys: config.locales?.sortKeys ?? "alphabetical",
1056
1120
  });
@@ -1065,7 +1129,11 @@ async function handleRewriteShape(
1065
1129
  }
1066
1130
 
1067
1131
  // Rewrite all locales to the target format
1068
- const stats = await localeStore.rewriteShape(targetFormat, { delimiter });
1132
+ const rewriteResult = await localeStore.rewriteShape(targetFormat, { delimiter });
1133
+ if (!rewriteResult.success) {
1134
+ throw new CliError(`Failed to rewrite locales: ${rewriteResult.error.message}`);
1135
+ }
1136
+ const stats = rewriteResult.data;
1069
1137
 
1070
1138
  if (stats.length === 0) {
1071
1139
  console.log(chalk.yellow(" No locale files found to rewrite."));
@@ -227,7 +227,14 @@ export function registerTransform(program: Command) {
227
227
  : writeEnabled
228
228
  ? 'Running transform (write mode)...'
229
229
  : 'Planning transform (dry-run)...';
230
- console.log(chalk.blue(banner));
230
+ // When JSON output is requested, avoid human-readable preamble on stdout
231
+ // so callers can reliably parse the JSON summary. Send banner to stderr
232
+ // when --json is used.
233
+ if (options.json) {
234
+ console.error(chalk.blue(banner));
235
+ } else {
236
+ console.log(chalk.blue(banner));
237
+ }
231
238
 
232
239
  try {
233
240
  const { config, projectRoot, configPath } = await loadConfigWithMeta(options.config);
package/src/index.ts CHANGED
@@ -24,7 +24,7 @@ export const program = new Command();
24
24
  program
25
25
  .name('i18nsmith')
26
26
  .description('Universal Automated i18n Library')
27
- .version('0.4.3');
27
+ .version('0.6.0');
28
28
 
29
29
  registerInit(program);
30
30
  registerScaffoldAdapter(program);
@@ -23,16 +23,25 @@ function runCli(
23
23
  args: string[],
24
24
  options: { cwd?: string } = {}
25
25
  ): { stdout: string; stderr: string; output: string; exitCode: number } {
26
+ // Clear known debug env vars so the test output is deterministic even when
27
+ // developer environments set DEBUG_* flags. Preserve essential env vars.
28
+ const env: NodeJS.ProcessEnv = {
29
+ ...process.env,
30
+ CI: 'true',
31
+ NO_COLOR: '1',
32
+ FORCE_COLOR: '0',
33
+ };
34
+ // Remove any DEBUG_* flags that may leak into the spawned CLI process
35
+ delete env.DEBUG_VUE_PARSER;
36
+ delete env.DEBUG_REFEXT;
37
+ delete env.DEBUG_SYNC_REF;
38
+ delete env.DEBUG_VUE_MUTATE;
39
+
26
40
  const result = spawnSync('node', [CLI_PATH, ...args], {
27
41
  cwd: options.cwd ?? process.cwd(),
28
42
  encoding: 'utf8',
29
43
  timeout: 30000,
30
- env: {
31
- ...process.env,
32
- CI: 'true',
33
- NO_COLOR: '1',
34
- FORCE_COLOR: '0',
35
- },
44
+ env,
36
45
  });
37
46
 
38
47
  // Log errors for debugging
@@ -53,11 +62,48 @@ function runCli(
53
62
 
54
63
  // Helper to extract JSON from CLI output (may contain log messages before JSON)
55
64
  function extractJson<T>(output: string): T {
56
- const jsonMatch = output.match(/\{[\s\S]*\}/);
57
- if (!jsonMatch) {
65
+ // Use brace-counting to find all complete top-level JSON objects in the output.
66
+ // This correctly handles nested braces (arrays/objects inside the top-level object).
67
+ const candidates: string[] = [];
68
+ let i = 0;
69
+ while (i < output.length) {
70
+ if (output[i] === '{') {
71
+ let depth = 0;
72
+ let inString = false;
73
+ let escape = false;
74
+ let j = i;
75
+ for (; j < output.length; j++) {
76
+ const ch = output[j];
77
+ if (escape) { escape = false; continue; }
78
+ if (ch === '\\' && inString) { escape = true; continue; }
79
+ if (ch === '"') { inString = !inString; continue; }
80
+ if (inString) continue;
81
+ if (ch === '{') depth++;
82
+ else if (ch === '}') {
83
+ depth--;
84
+ if (depth === 0) { candidates.push(output.slice(i, j + 1)); break; }
85
+ }
86
+ }
87
+ i = j + 1;
88
+ } else {
89
+ i++;
90
+ }
91
+ }
92
+
93
+ if (!candidates.length) {
58
94
  throw new Error(`No JSON found in output: ${output.slice(0, 200)}...`);
59
95
  }
60
- return JSON.parse(jsonMatch[0]);
96
+
97
+ // Prefer the largest candidate (most likely the top-level summary).
98
+ candidates.sort((a, b) => b.length - a.length);
99
+ for (const candidate of candidates) {
100
+ try {
101
+ return JSON.parse(candidate);
102
+ } catch (_err) {
103
+ // try next candidate
104
+ }
105
+ }
106
+ throw new Error(`No valid JSON block found in output: ${output.slice(0, 400)}...`);
61
107
  }
62
108
 
63
109
  describe('CLI Integration Tests', () => {
@@ -188,7 +234,7 @@ export function App() {
188
234
  );
189
235
 
190
236
  const result = runCli(['scan', '--json'], { cwd: tmpDir });
191
- const parsed = extractJson<{ filesScanned: number; candidates: unknown[] }>(result.stdout);
237
+ const parsed = extractJson<{ filesScanned: number; candidates: unknown[] }>(result.output);
192
238
 
193
239
  expect(parsed).toHaveProperty('filesScanned');
194
240
  expect(parsed).toHaveProperty('candidates');
@@ -333,6 +379,61 @@ export function App() {
333
379
  expect(backupExists).toBe(true);
334
380
  });
335
381
 
382
+ it('CLI end-to-end: detects nested $t inside template object args (I18nDemo.vue)', async () => {
383
+ // Setup Vue SFC that uses a nested $t(...) inside an interpolation object
384
+ const vue = `
385
+ <template>
386
+ <p v-if="name">{{ $t('common.components.i18ndemo.arg0-name.4ac48a', { arg0: $t('demo.card.greeting'), name }) }}</p>
387
+ </template>
388
+ `;
389
+
390
+ await fs.mkdir(path.join(tmpDir, 'src', 'components'), { recursive: true });
391
+ await fs.writeFile(path.join(tmpDir, 'src', 'components', 'I18nDemo.vue'), vue);
392
+
393
+ const en = {
394
+ common: { components: { i18ndemo: { 'arg0-name': '{arg0} {name}' } } },
395
+ demo: { card: { greeting: 'Hello' } }
396
+ };
397
+ const es = {
398
+ common: { components: { i18ndemo: { 'arg0-name': '{arg0} {name}' } } },
399
+ demo: { card: { greeting: 'Hola' } }
400
+ };
401
+
402
+ await fs.writeFile(path.join(tmpDir, 'locales', 'en.json'), JSON.stringify(en, null, 2));
403
+ await fs.writeFile(path.join(tmpDir, 'locales', 'fr.json'), JSON.stringify(es, null, 2));
404
+
405
+ // Update config to include .vue files for this test
406
+ const cfgPath = path.join(tmpDir, 'i18n.config.json');
407
+ const cfg = JSON.parse(await fs.readFile(cfgPath, 'utf8'));
408
+ cfg.include = ['src/**/*.vue', 'src/**/*.{ts,js,tsx}'];
409
+ await fs.writeFile(cfgPath, JSON.stringify(cfg, null, 2));
410
+
411
+ const result = runCli(['sync', '--json'], { cwd: tmpDir });
412
+ expect(result.exitCode).toBe(0);
413
+
414
+ // Sanity check: CLI output should contain the quoted key somewhere so
415
+ // editors/CI that scan the output can detect the reference even when
416
+ // there are additional log lines present.
417
+ expect(result.output).toContain('"demo.card.greeting"');
418
+
419
+ // Also try to parse JSON summary if possible and validate references.
420
+ // If parsing fails during CI or debugging, print raw output for diagnosis.
421
+ try {
422
+ const parsed = extractJson<any>(result.output);
423
+ const referenced = parsed.references.map((r: any) => r.key);
424
+ const unused = parsed.unusedKeys.map((u: any) => u.key);
425
+
426
+ expect(referenced).toContain('demo.card.greeting');
427
+ expect(unused).not.toContain('demo.card.greeting');
428
+ } catch (err) {
429
+ // Dump output for debugging in test logs then rethrow so CI shows failure
430
+ // (this helps capture the raw CLI output when test fails).
431
+ // eslint-disable-next-line no-console
432
+ console.error('CLI raw output (truncated):', result.output.slice(0, 4000));
433
+ throw err;
434
+ }
435
+ });
436
+
336
437
  it('should skip backup with --no-backup', async () => {
337
438
  await fs.writeFile(
338
439
  path.join(tmpDir, 'src', 'App.tsx'),
@@ -427,6 +528,38 @@ export function App() {
427
528
  expect(result.output).toContain('Planning transform (dry-run)');
428
529
  expect(result.exitCode).toBe(0);
429
530
  });
531
+
532
+ it('extraction should not include structural opening punctuation (Items ({count}) case)', async () => {
533
+ const content = `export function App() {
534
+ const count = 5;
535
+ const items = ['a','b'];
536
+ return (
537
+ <>
538
+ <p>Items ({count}): {items.join(', ')}</p>
539
+ <p>{'Items (' + count + ')'}</p>
540
+ </>
541
+ );
542
+ }`;
543
+
544
+ await fs.writeFile(path.join(tmpDir, 'src', 'App.tsx'), content);
545
+
546
+ const result = runCli(['transform', '--json'], { cwd: tmpDir });
547
+ expect(result.exitCode).toBe(0);
548
+ const parsed = extractJson<any>(result.output);
549
+ const candidates = parsed.candidates || [];
550
+
551
+ // No candidate text should include the structural '(' token before the placeholder
552
+ const hasBadText = candidates.some((c: any) => typeof c.text === 'string' && c.text.includes('Items ('));
553
+ expect(hasBadText).toBe(false);
554
+
555
+ // Find the Items-related candidate and assert its suggestedKey is derived from the static "Items"
556
+ const itemsCandidate = candidates.find((c: any) => typeof c.text === 'string' && /Items/.test(c.text));
557
+ expect(itemsCandidate).toBeDefined();
558
+ expect(itemsCandidate.text).not.toContain('(');
559
+ if (itemsCandidate.interpolation) {
560
+ expect(itemsCandidate.interpolation.template).not.toMatch(/Items \(/);
561
+ }
562
+ });
430
563
  });
431
564
 
432
565
  describe('check command', () => {
@@ -477,7 +610,7 @@ export function App() {
477
610
  );
478
611
 
479
612
  const result = runCli(['check', '--json'], { cwd: tmpDir });
480
- const parsed = extractJson<{ diagnostics: unknown; sync: unknown }>(result.stdout);
613
+ const parsed = extractJson<{ diagnostics: unknown; sync: unknown }>(result.output);
481
614
 
482
615
  expect(parsed).toHaveProperty('diagnostics');
483
616
  expect(parsed).toHaveProperty('sync');
@@ -524,7 +657,7 @@ export function App() {
524
657
  );
525
658
 
526
659
  const result = runCli(['check', '--audit', '--json'], { cwd: tmpDir });
527
- const parsed = extractJson<{ audit?: { totalQualityIssues: number } }>(result.stdout);
660
+ const parsed = extractJson<{ audit?: { totalQualityIssues: number } }>(result.output);
528
661
 
529
662
  expect(parsed).toHaveProperty('audit');
530
663
  expect(parsed.audit?.totalQualityIssues ?? 0).toBeGreaterThan(0);
@@ -0,0 +1,12 @@
1
+ /**
2
+ * @fileoverview
3
+ * CLI Services Module
4
+ *
5
+ * CLI-level service implementations that cannot live in @i18nsmith/core
6
+ * due to dependency constraints.
7
+ *
8
+ * @module @i18nsmith/cli/services
9
+ */
10
+
11
+ export { CliTransformService, getTransformService } from './transform-service.js';
12
+ export type { CliTransformServiceConfig } from './transform-service.js';