i18nsmith 0.2.0 → 0.2.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.
@@ -1 +1 @@
1
- {"version":3,"file":"transform.d.ts","sourceRoot":"","sources":["../../src/commands/transform.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAwEzC,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,OAAO,QAsGjD"}
1
+ {"version":3,"file":"transform.d.ts","sourceRoot":"","sources":["../../src/commands/transform.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAsJzC,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,OAAO,QAyGjD"}
package/dist/index.js CHANGED
@@ -108399,6 +108399,9 @@ async function formatFileWithPrettier(filePath, loadPrettier = defaultLoader) {
108399
108399
  var Transformer = class _Transformer {
108400
108400
  constructor(config, options8 = {}) {
108401
108401
  this.config = config;
108402
+ // Per-run dedupe state. Must be cleared at the beginning of each run.
108403
+ // Keeping it on the instance caused subsequent runs to mislabel candidates as duplicates
108404
+ // and could lead to confusing multi-pass behavior/reporting.
108402
108405
  this.seenHashes = /* @__PURE__ */ new Map();
108403
108406
  this.workspaceRoot = options8.workspaceRoot ?? process.cwd();
108404
108407
  this.project = options8.project ?? new Project6({ skipAddingFilesFromTsConfig: true });
@@ -108418,6 +108421,7 @@ var Transformer = class _Transformer {
108418
108421
  this.translationAdapter = this.normalizeTranslationAdapter(config.translationAdapter);
108419
108422
  }
108420
108423
  async run(runOptions = {}) {
108424
+ this.seenHashes.clear();
108421
108425
  const write = runOptions.write ?? this.defaultWrite;
108422
108426
  const generateDiffs = runOptions.diff ?? false;
108423
108427
  if (write) {
@@ -108439,15 +108443,47 @@ var Transformer = class _Transformer {
108439
108443
  scanCalls: runOptions.migrateTextKeys
108440
108444
  });
108441
108445
  const enriched = await this.enrichCandidates(summary.detailedCandidates);
108446
+ const transformableCandidates = enriched.filter(
108447
+ (candidate) => candidate.status === "pending" || candidate.status === "existing"
108448
+ );
108442
108449
  const filesChanged = /* @__PURE__ */ new Map();
108443
108450
  const skippedFiles = [];
108444
108451
  const shouldProcess = write || generateDiffs;
108452
+ const enableProgress = Boolean(write && runOptions.onProgress && transformableCandidates.length > 0);
108453
+ const progressState = {
108454
+ processed: 0,
108455
+ applied: 0,
108456
+ skipped: 0,
108457
+ errors: 0,
108458
+ total: transformableCandidates.length
108459
+ };
108460
+ const emitProgress = () => {
108461
+ if (!enableProgress || !runOptions.onProgress) {
108462
+ return;
108463
+ }
108464
+ const percent = progressState.total === 0 ? 100 : Math.min(100, Math.round(progressState.processed / progressState.total * 100));
108465
+ const payload = {
108466
+ stage: "apply",
108467
+ processed: progressState.processed,
108468
+ total: progressState.total,
108469
+ percent,
108470
+ applied: progressState.applied,
108471
+ skipped: progressState.skipped,
108472
+ remaining: Math.max(progressState.total - progressState.processed, 0),
108473
+ errors: progressState.errors,
108474
+ message: "Applying transformations"
108475
+ };
108476
+ runOptions.onProgress(payload);
108477
+ };
108478
+ emitProgress();
108445
108479
  if (shouldProcess) {
108446
108480
  const fileImportCache = /* @__PURE__ */ new Map();
108447
108481
  for (const candidate of enriched) {
108448
108482
  if (candidate.status !== "pending" && candidate.status !== "existing") {
108449
108483
  continue;
108450
108484
  }
108485
+ progressState.processed += 1;
108486
+ let skipCounted = false;
108451
108487
  const wasExisting = candidate.status === "existing";
108452
108488
  try {
108453
108489
  const sourceFile = candidate.raw.sourceFile;
@@ -108478,6 +108514,9 @@ var Transformer = class _Transformer {
108478
108514
  candidate.status = "skipped";
108479
108515
  candidate.reason = "No React component/function scope found";
108480
108516
  skippedFiles.push({ filePath: candidate.filePath, reason: candidate.reason });
108517
+ progressState.skipped += 1;
108518
+ skipCounted = true;
108519
+ emitProgress();
108481
108520
  continue;
108482
108521
  }
108483
108522
  if (write) {
@@ -108486,6 +108525,7 @@ var Transformer = class _Transformer {
108486
108525
  this.applyCandidate(candidate);
108487
108526
  candidate.status = "applied";
108488
108527
  filesChanged.set(candidate.filePath, sourceFile);
108528
+ progressState.applied += 1;
108489
108529
  }
108490
108530
  if (!wasExisting) {
108491
108531
  const sourceValue = await this.findLegacyLocaleValue(this.sourceLocale, candidate.text) ?? candidate.text ?? generateValueFromKey(candidate.suggestedKey);
@@ -108502,15 +108542,30 @@ var Transformer = class _Transformer {
108502
108542
  candidate.status = "skipped";
108503
108543
  candidate.reason = error.message;
108504
108544
  skippedFiles.push({ filePath: candidate.filePath, reason: candidate.reason });
108545
+ progressState.skipped += 1;
108546
+ progressState.errors += 1;
108547
+ skipCounted = true;
108548
+ }
108549
+ if (candidate.status === "skipped" && !skipCounted) {
108550
+ progressState.skipped += 1;
108551
+ skipCounted = true;
108505
108552
  }
108553
+ emitProgress();
108506
108554
  }
108507
108555
  if (write) {
108556
+ const changedFiles = Array.from(filesChanged.values());
108508
108557
  await Promise.all(
108509
- Array.from(filesChanged.values()).map(async (file) => {
108558
+ changedFiles.map(async (file) => {
108510
108559
  await file.save();
108511
108560
  await formatFileWithPrettier(file.getFilePath());
108512
108561
  })
108513
108562
  );
108563
+ for (const file of changedFiles) {
108564
+ try {
108565
+ file.refreshFromFileSystemSync();
108566
+ } catch {
108567
+ }
108568
+ }
108514
108569
  }
108515
108570
  }
108516
108571
  const localeStats = write ? await this.localeStore.flush() : [];
@@ -108581,7 +108636,7 @@ var Transformer = class _Transformer {
108581
108636
  const generated = this.keyGenerator.generate(candidate.text, this.buildContext(candidate));
108582
108637
  let status = "pending";
108583
108638
  let reason;
108584
- const { node: _node, sourceFile: _sourceFile, ...serializableCandidate } = candidate;
108639
+ const { node, sourceFile, ...serializableCandidate } = candidate;
108585
108640
  const validation = this.keyValidator.validate(generated.key, candidate.text);
108586
108641
  if (!validation.valid) {
108587
108642
  status = "skipped";
@@ -108711,9 +108766,21 @@ var collectTargetPatterns3 = (value, previous) => {
108711
108766
  return [...previous, ...tokens];
108712
108767
  };
108713
108768
  function printTransformSummary(summary) {
108769
+ const counts = summary.candidates.reduce(
108770
+ (acc, c7) => {
108771
+ acc.total += 1;
108772
+ if (c7.status === "applied") acc.applied += 1;
108773
+ else if (c7.status === "pending") acc.pending += 1;
108774
+ else if (c7.status === "duplicate") acc.duplicate += 1;
108775
+ else if (c7.status === "existing") acc.existing += 1;
108776
+ else if (c7.status === "skipped") acc.skipped += 1;
108777
+ return acc;
108778
+ },
108779
+ { total: 0, applied: 0, pending: 0, duplicate: 0, existing: 0, skipped: 0 }
108780
+ );
108714
108781
  console.log(
108715
108782
  chalk14.green(
108716
- `Scanned ${summary.filesScanned} file${summary.filesScanned === 1 ? "" : "s"}; ${summary.candidates.length} candidate${summary.candidates.length === 1 ? "" : "s"} processed.`
108783
+ `Scanned ${summary.filesScanned} file${summary.filesScanned === 1 ? "" : "s"}; ${counts.total} candidate${counts.total === 1 ? "" : "s"} found (applied: ${counts.applied}, pending: ${counts.pending}, duplicates: ${counts.duplicate}, existing: ${counts.existing}, skipped: ${counts.skipped}).`
108717
108784
  )
108718
108785
  );
108719
108786
  const preview = summary.candidates.slice(0, 50).map((candidate) => ({
@@ -108725,6 +108792,15 @@ function printTransformSummary(summary) {
108725
108792
  Preview: candidate.text.length > 40 ? `${candidate.text.slice(0, 37)}...` : candidate.text
108726
108793
  }));
108727
108794
  console.table(preview);
108795
+ const pending = summary.candidates.filter((candidate) => candidate.status === "pending").length;
108796
+ if (pending > 0) {
108797
+ console.log(
108798
+ chalk14.yellow(
108799
+ `
108800
+ ${pending} candidate${pending === 1 ? "" : "s"} still pending. This can happen when candidates are filtered for safety (e.g., not in a React scope). Re-run with --write after reviewing skipped reasons if you want to keep iterating.`
108801
+ )
108802
+ );
108803
+ }
108728
108804
  if (summary.filesChanged.length) {
108729
108805
  console.log(chalk14.blue(`Files changed (${summary.filesChanged.length}):`));
108730
108806
  summary.filesChanged.forEach((file) => console.log(` \u2022 ${file}`));
@@ -108742,6 +108818,44 @@ function printTransformSummary(summary) {
108742
108818
  summary.skippedFiles.forEach((item) => console.log(` \u2022 ${item.filePath}: ${item.reason}`));
108743
108819
  }
108744
108820
  }
108821
+ function createProgressLogger() {
108822
+ let lastPercent = -1;
108823
+ let lastLogTime = 0;
108824
+ let pendingCarriageReturn = false;
108825
+ const writeLine = (line3, final = false) => {
108826
+ if (process.stdout.isTTY) {
108827
+ process.stdout.write(`\r${line3}`);
108828
+ pendingCarriageReturn = !final;
108829
+ if (final) {
108830
+ process.stdout.write("\n");
108831
+ }
108832
+ } else {
108833
+ console.log(line3);
108834
+ }
108835
+ };
108836
+ return {
108837
+ emit(progress) {
108838
+ if (progress.stage !== "apply" || progress.total === 0) {
108839
+ return;
108840
+ }
108841
+ const now = Date.now();
108842
+ const shouldLog = progress.processed === progress.total || progress.percent === 0 || progress.percent >= lastPercent + 2 || now - lastLogTime > 1500;
108843
+ if (!shouldLog) {
108844
+ return;
108845
+ }
108846
+ lastPercent = progress.percent;
108847
+ lastLogTime = now;
108848
+ const line3 = `Applying transforms ${progress.processed}/${progress.total} (${progress.percent}%) | applied: ${progress.applied ?? 0} | skipped: ${progress.skipped ?? 0}` + (progress.errors ? ` | errors: ${progress.errors}` : "") + ` | remaining: ${progress.remaining ?? progress.total - progress.processed}`;
108849
+ writeLine(line3, progress.processed === progress.total);
108850
+ },
108851
+ flush() {
108852
+ if (pendingCarriageReturn && process.stdout.isTTY) {
108853
+ process.stdout.write("\n");
108854
+ pendingCarriageReturn = false;
108855
+ }
108856
+ }
108857
+ };
108858
+ }
108745
108859
  function registerTransform(program2) {
108746
108860
  program2.command("transform").description("Scan project and apply i18n transformations").option("-c, --config <path>", "Path to i18nsmith config file", "i18n.config.json").option("--json", "Print raw JSON results", false).option("--report <path>", "Write JSON summary to a file (for CI or editors)").option("--write", "Write changes to disk (defaults to dry-run)", false).option("--check", "Exit with error code if changes are needed", false).option("--diff", "Display unified diffs for locale files that would change", false).option("--patch-dir <path>", "Write locale diffs to .patch files in the specified directory").option("--target <pattern...>", "Limit scanning to specific files or glob patterns", collectTargetPatterns3, []).option("--migrate-text-keys", 'Migrate existing t("Text") calls to structured keys').option("--preview-output <path>", "Write preview summary (JSON) to a file (implies dry-run)").option("--apply-preview <path>", "Apply a previously saved transform preview JSON file safely").action(async (options8) => {
108747
108861
  if (options8.applyPreview) {
@@ -108767,12 +108881,15 @@ function registerTransform(program2) {
108767
108881
  `));
108768
108882
  }
108769
108883
  const transformer = new Transformer(config, { workspaceRoot: projectRoot });
108884
+ const progressLogger = createProgressLogger();
108770
108885
  const summary = await transformer.run({
108771
108886
  write: options8.write,
108772
108887
  targets: options8.target,
108773
108888
  diff: diffRequested,
108774
- migrateTextKeys: options8.migrateTextKeys
108889
+ migrateTextKeys: options8.migrateTextKeys,
108890
+ onProgress: progressLogger.emit
108775
108891
  });
108892
+ progressLogger.flush();
108776
108893
  if (previewMode && options8.previewOutput) {
108777
108894
  const savedPath = await writePreviewFile("transform", summary, options8.previewOutput);
108778
108895
  console.log(chalk14.green(`Preview written to ${path33.relative(process.cwd(), savedPath)}`));
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=preview.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"preview.test.d.ts","sourceRoot":"","sources":["../../src/utils/preview.test.ts"],"names":[],"mappings":""}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "i18nsmith",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "CLI for i18nsmith",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -4,7 +4,7 @@ import chalk from 'chalk';
4
4
  import type { Command } from 'commander';
5
5
  import { loadConfigWithMeta } from '@i18nsmith/core';
6
6
  import { Transformer } from '@i18nsmith/transformer';
7
- import type { TransformSummary } from '@i18nsmith/transformer';
7
+ import type { TransformProgress, TransformSummary } from '@i18nsmith/transformer';
8
8
  import { printLocaleDiffs, writeLocaleDiffPatches } from '../utils/diff-utils.js';
9
9
  import { applyPreviewFile, writePreviewFile } from '../utils/preview.js';
10
10
 
@@ -32,10 +32,24 @@ const collectTargetPatterns = (value: string | string[], previous: string[]) =>
32
32
  };
33
33
 
34
34
  function printTransformSummary(summary: TransformSummary) {
35
+ const counts = summary.candidates.reduce(
36
+ (acc, c) => {
37
+ acc.total += 1;
38
+ if (c.status === 'applied') acc.applied += 1;
39
+ else if (c.status === 'pending') acc.pending += 1;
40
+ else if (c.status === 'duplicate') acc.duplicate += 1;
41
+ else if (c.status === 'existing') acc.existing += 1;
42
+ else if (c.status === 'skipped') acc.skipped += 1;
43
+ return acc;
44
+ },
45
+ { total: 0, applied: 0, pending: 0, duplicate: 0, existing: 0, skipped: 0 }
46
+ );
47
+
35
48
  console.log(
36
49
  chalk.green(
37
50
  `Scanned ${summary.filesScanned} file${summary.filesScanned === 1 ? '' : 's'}; ` +
38
- `${summary.candidates.length} candidate${summary.candidates.length === 1 ? '' : 's'} processed.`
51
+ `${counts.total} candidate${counts.total === 1 ? '' : 's'} found ` +
52
+ `(applied: ${counts.applied}, pending: ${counts.pending}, duplicates: ${counts.duplicate}, existing: ${counts.existing}, skipped: ${counts.skipped}).`
39
53
  )
40
54
  );
41
55
 
@@ -53,6 +67,17 @@ function printTransformSummary(summary: TransformSummary) {
53
67
 
54
68
  console.table(preview);
55
69
 
70
+ const pending = summary.candidates.filter((candidate) => candidate.status === 'pending').length;
71
+ if (pending > 0) {
72
+ console.log(
73
+ chalk.yellow(
74
+ `\n${pending} candidate${pending === 1 ? '' : 's'} still pending. ` +
75
+ `This can happen when candidates are filtered for safety (e.g., not in a React scope). ` +
76
+ `Re-run with --write after reviewing skipped reasons if you want to keep iterating.`
77
+ )
78
+ );
79
+ }
80
+
56
81
  if (summary.filesChanged.length) {
57
82
  console.log(chalk.blue(`Files changed (${summary.filesChanged.length}):`));
58
83
  summary.filesChanged.forEach((file) => console.log(` • ${file}`));
@@ -73,6 +98,59 @@ function printTransformSummary(summary: TransformSummary) {
73
98
  }
74
99
  }
75
100
 
101
+ function createProgressLogger() {
102
+ let lastPercent = -1;
103
+ let lastLogTime = 0;
104
+ let pendingCarriageReturn = false;
105
+
106
+ const writeLine = (line: string, final = false) => {
107
+ if (process.stdout.isTTY) {
108
+ process.stdout.write(`\r${line}`);
109
+ pendingCarriageReturn = !final;
110
+ if (final) {
111
+ process.stdout.write('\n');
112
+ }
113
+ } else {
114
+ console.log(line);
115
+ }
116
+ };
117
+
118
+ return {
119
+ emit(progress: TransformProgress) {
120
+ if (progress.stage !== 'apply' || progress.total === 0) {
121
+ return;
122
+ }
123
+
124
+ const now = Date.now();
125
+ const shouldLog =
126
+ progress.processed === progress.total ||
127
+ progress.percent === 0 ||
128
+ progress.percent >= lastPercent + 2 ||
129
+ now - lastLogTime > 1500;
130
+
131
+ if (!shouldLog) {
132
+ return;
133
+ }
134
+
135
+ lastPercent = progress.percent;
136
+ lastLogTime = now;
137
+ const line =
138
+ `Applying transforms ${progress.processed}/${progress.total} (${progress.percent}%)` +
139
+ ` | applied: ${progress.applied ?? 0}` +
140
+ ` | skipped: ${progress.skipped ?? 0}` +
141
+ (progress.errors ? ` | errors: ${progress.errors}` : '') +
142
+ ` | remaining: ${progress.remaining ?? progress.total - progress.processed}`;
143
+ writeLine(line, progress.processed === progress.total);
144
+ },
145
+ flush() {
146
+ if (pendingCarriageReturn && process.stdout.isTTY) {
147
+ process.stdout.write('\n');
148
+ pendingCarriageReturn = false;
149
+ }
150
+ },
151
+ };
152
+ }
153
+
76
154
  export function registerTransform(program: Command) {
77
155
  program
78
156
  .command('transform')
@@ -122,12 +200,15 @@ export function registerTransform(program: Command) {
122
200
  }
123
201
 
124
202
  const transformer = new Transformer(config, { workspaceRoot: projectRoot });
203
+ const progressLogger = createProgressLogger();
125
204
  const summary = await transformer.run({
126
205
  write: options.write,
127
206
  targets: options.target,
128
207
  diff: diffRequested,
129
208
  migrateTextKeys: options.migrateTextKeys,
209
+ onProgress: progressLogger.emit,
130
210
  });
211
+ progressLogger.flush();
131
212
 
132
213
  if (previewMode && options.previewOutput) {
133
214
  const savedPath = await writePreviewFile('transform', summary, options.previewOutput);
package/src/e2e.test.ts CHANGED
@@ -144,6 +144,50 @@ describe('E2E Fixture Tests', () => {
144
144
 
145
145
  expect(parsed).toHaveProperty('diagnostics');
146
146
  });
147
+
148
+ it('applies preview selections to add missing keys and prune unused keys', async () => {
149
+ const appFile = path.join(fixtureDir, 'src', 'App.tsx');
150
+ const localeFile = path.join(fixtureDir, 'locales', 'en.json');
151
+ const previewDir = path.join(fixtureDir, '.i18nsmith', 'previews');
152
+ const previewPath = path.join(previewDir, 'integration-preview.json');
153
+ const selectionPath = path.join(previewDir, 'integration-selection.json');
154
+
155
+ const appContent = await fs.readFile(appFile, 'utf8');
156
+ const injected = appContent.replace(
157
+ '</div>',
158
+ " <p>{t('integration.preview-missing')}</p>\n </div>"
159
+ );
160
+ await fs.writeFile(appFile, injected, 'utf8');
161
+
162
+ const locale = JSON.parse(await fs.readFile(localeFile, 'utf8'));
163
+ locale['unused.preview.key'] = 'Preview-only key';
164
+ await fs.writeFile(localeFile, JSON.stringify(locale, null, 2));
165
+
166
+ const previewResult = runCli(['sync', '--preview-output', previewPath], { cwd: fixtureDir });
167
+ expect(previewResult.exitCode).toBe(0);
168
+
169
+ const payload = JSON.parse(await fs.readFile(previewPath, 'utf8')) as {
170
+ summary: { missingKeys: { key: string }[]; unusedKeys: { key: string }[] };
171
+ };
172
+ const selection = {
173
+ missing: payload.summary.missingKeys.map((entry) => entry.key),
174
+ unused: payload.summary.unusedKeys.map((entry) => entry.key),
175
+ };
176
+ expect(selection.missing).toContain('integration.preview-missing');
177
+ expect(selection.unused).toContain('unused.preview.key');
178
+ await fs.mkdir(previewDir, { recursive: true });
179
+ await fs.writeFile(selectionPath, JSON.stringify(selection, null, 2));
180
+
181
+ const applyResult = runCli(
182
+ ['sync', '--apply-preview', previewPath, '--selection-file', selectionPath, '--prune', '--yes'],
183
+ { cwd: fixtureDir }
184
+ );
185
+ expect(applyResult.exitCode).toBe(0);
186
+
187
+ const finalLocale = JSON.parse(await fs.readFile(localeFile, 'utf8'));
188
+ expect(finalLocale).toHaveProperty('integration.preview-missing');
189
+ expect(finalLocale).not.toHaveProperty('unused.preview.key');
190
+ });
147
191
  });
148
192
 
149
193
  describe('suspicious-keys fixture', () => {
@@ -0,0 +1,94 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import fs from 'fs/promises';
3
+ import os from 'os';
4
+ import path from 'path';
5
+ import { fileURLToPath } from 'url';
6
+
7
+ const __filename = fileURLToPath(import.meta.url);
8
+ const __dirname = path.dirname(__filename);
9
+
10
+ const mockSpawn = vi.fn();
11
+
12
+ vi.mock('child_process', () => ({
13
+ spawn: (...args: Parameters<typeof mockSpawn>) => mockSpawn(...args),
14
+ }));
15
+
16
+ async function writePreviewFixture(args: string[]): Promise<string> {
17
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'preview-test-'));
18
+ const previewPath = path.join(tempDir, 'sync-preview.json');
19
+ const payload = {
20
+ type: 'sync-preview',
21
+ version: 1,
22
+ command: 'i18nsmith sync --preview-output tmp.json',
23
+ args,
24
+ timestamp: new Date().toISOString(),
25
+ summary: { missingKeys: [], unusedKeys: [] },
26
+ };
27
+ await fs.writeFile(previewPath, JSON.stringify(payload, null, 2), 'utf8');
28
+ return previewPath;
29
+ }
30
+
31
+ function mockSuccessfulSpawn() {
32
+ mockSpawn.mockImplementation(() => {
33
+ const child = {
34
+ on(event: string, handler: (code?: number) => void) {
35
+ if (event === 'exit') {
36
+ setImmediate(() => handler(0));
37
+ }
38
+ return child;
39
+ },
40
+ } as unknown as ReturnType<typeof mockSpawn>;
41
+ return child;
42
+ });
43
+ }
44
+
45
+ describe('applyPreviewFile', () => {
46
+ beforeEach(() => {
47
+ mockSpawn.mockReset();
48
+ mockSuccessfulSpawn();
49
+ });
50
+
51
+ afterEach(() => {
52
+ vi.restoreAllMocks();
53
+ });
54
+
55
+ it('replays preview with --write and extra args (selection + prune)', async () => {
56
+ const previewPath = await writePreviewFixture([
57
+ 'sync',
58
+ '--target',
59
+ 'src/app/**',
60
+ '--preview-output',
61
+ 'tmp.json',
62
+ ]);
63
+ const selectionFile = path.join(path.dirname(previewPath), 'selection.json');
64
+
65
+ const { applyPreviewFile } = await import('./preview.js');
66
+
67
+ await applyPreviewFile('sync', previewPath, ['--selection-file', selectionFile, '--prune']);
68
+
69
+ expect(mockSpawn).toHaveBeenCalledTimes(1);
70
+ const [, spawnedArgs] = mockSpawn.mock.calls[0];
71
+ const [, ...forwardedArgs] = spawnedArgs as string[];
72
+ expect(forwardedArgs).toEqual([
73
+ 'sync',
74
+ '--target',
75
+ 'src/app/**',
76
+ '--write',
77
+ '--selection-file',
78
+ selectionFile,
79
+ '--prune',
80
+ ]);
81
+ });
82
+
83
+ it('does not duplicate --write when already present in preview args', async () => {
84
+ const previewPath = await writePreviewFixture(['sync', '--write', '--json']);
85
+ const { applyPreviewFile } = await import('./preview.js');
86
+
87
+ await applyPreviewFile('sync', previewPath);
88
+
89
+ expect(mockSpawn).toHaveBeenCalledTimes(1);
90
+ const [, spawnedArgs] = mockSpawn.mock.calls[0];
91
+ const [, ...forwardedArgs] = spawnedArgs as string[];
92
+ expect(forwardedArgs).toEqual(['sync', '--write', '--json']);
93
+ });
94
+ });