@astryxdesign/cli 0.6.3-canary.7210f0d → 0.6.3-canary.7ada42e

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 (40) hide show
  1. package/README.md +2 -0
  2. package/api/upgrade/_adapter.d.mts +22 -4
  3. package/api/upgrade/_adapter.mjs +19 -6
  4. package/api/upgrade/run/run.mjs +355 -59
  5. package/api/upgrade/upgrade.doc.mjs +5 -1
  6. package/api/upgrade/upgrade.type.d.mts +34 -0
  7. package/api/upgrade/upgrade.type.mjs +15 -0
  8. package/assets/codemods/__tests__/runner.test.mjs +330 -8
  9. package/assets/codemods/integration-runner.mjs +56 -4
  10. package/assets/codemods/integration-runner.protection.test.mjs +153 -0
  11. package/assets/codemods/run-codemod.mjs +177 -34
  12. package/assets/codemods/runner.mjs +248 -110
  13. package/assets/codemods/transforms/next/__tests__/migrate-native-picker-to-presentation.test.mjs +63 -0
  14. package/assets/codemods/transforms/next/__tests__/migrate-theme-catalog-to-descriptors.test.mjs +4 -0
  15. package/assets/codemods/transforms/next/index.mjs +8 -0
  16. package/assets/codemods/transforms/next/migrate-native-picker-to-presentation.mjs +148 -0
  17. package/assets/docs/cli-integrations.doc.mjs +13 -0
  18. package/assets/templates/blocks/components/DateInput/DateInputDateRange.tsx +1 -1
  19. package/assets/templates/blocks/components/Item/ItemDocumentTabs.doc.mjs +14 -0
  20. package/assets/templates/blocks/components/Item/ItemDocumentTabs.tsx +100 -0
  21. package/assets/templates/blocks/components/TimeInput/TimeInputConstrained.tsx +1 -0
  22. package/authoring/codemod/type.ts +12 -0
  23. package/clients/cli/commands/build-theme.mjs +10 -44
  24. package/clients/cli/commands/docs.mjs +1 -1
  25. package/clients/cli/commands/text-json-parity.test.mjs +719 -0
  26. package/clients/cli/commands/theme-targets.behavior.test.mjs +4 -3
  27. package/clients/cli/commands/upgrade.doc.mjs +2 -1
  28. package/clients/cli/commands/upgrade.file-protection.test.mjs +228 -0
  29. package/clients/cli/commands/upgrade.mjs +29 -7
  30. package/clients/cli/formatters/index.mjs +2 -0
  31. package/clients/cli/formatters/index.test.mjs +6 -0
  32. package/foundation/fs/file-protection.d.mts +33 -0
  33. package/foundation/fs/file-protection.mjs +825 -0
  34. package/foundation/fs/file-protection.test.mjs +250 -0
  35. package/foundation/response/error-codes.d.mts +3 -1
  36. package/foundation/response/error-codes.d.ts +2 -0
  37. package/foundation/response/error-codes.doc.mjs +10 -0
  38. package/foundation/response/error-codes.mjs +6 -0
  39. package/foundation/response/error-codes.test.mjs +55 -11
  40. package/package.json +10 -9
@@ -121,7 +121,7 @@ export const doc = {
121
121
  {
122
122
  type: 'upgrade.run',
123
123
  description:
124
- 'The terminal run receipt: from/to versions, the codemod count, integrations processed, the agent-docs summary, an optional copied-composition registry summary, and (apply mode) filesChanged, transformsApplied, and any per-codemod errors.',
124
+ 'The terminal run receipt: from/to versions, codemod count, integrations processed, agent-docs and registry summaries, modifiedFiles, protectedFiles, declinedCandidates, and completion state. A protected required change returns complete: false with ERR_CODEMOD_PROTECTED; the CLI exits nonzero while preserving the structured receipt.',
125
125
  },
126
126
  ],
127
127
  throws: [
@@ -147,6 +147,10 @@ export const doc = {
147
147
  code: 'ERR_CODEMOD_FAILED',
148
148
  when: 'one or more codemods failed, or a post-codemod hook failed',
149
149
  },
150
+ {
151
+ code: 'ERR_CODEMOD_PROTECTION_SOURCE',
152
+ when: 'a working-tree protection declaration cannot be read or parsed before writes begin',
153
+ },
150
154
  ],
151
155
  examples: [
152
156
  {
@@ -131,6 +131,31 @@ export type UpgradeRegistryResponse = {
131
131
  type: "upgrade.registry";
132
132
  data: RegistryCompositionSummary;
133
133
  };
134
+ /**
135
+ * A required codemod change blocked by effective working-tree protection.
136
+ */
137
+ export type ProtectedCodemodFile = {
138
+ /**
139
+ * Project-relative path.
140
+ */
141
+ file: string;
142
+ /**
143
+ * Codemods that still require a change.
144
+ */
145
+ codemods: string[];
146
+ /**
147
+ * Protection categories (generated, vendored, ignored, and hard boundaries).
148
+ */
149
+ reasons: string[];
150
+ /**
151
+ * Effective declarations that protect the file.
152
+ */
153
+ declarations: string[];
154
+ /**
155
+ * Exact regeneration commands found in generated headers, when present.
156
+ */
157
+ commands: string[];
158
+ };
134
159
  /**
135
160
  * astryx --json upgrade [--apply]
136
161
  */
@@ -144,7 +169,16 @@ export type UpgradeRunResponse = {
144
169
  agentDocsRefreshed: boolean;
145
170
  agentDocs: AgentDocsSummary;
146
171
  registryCompositions?: RegistryCompositionSummary | undefined;
172
+ complete?: boolean | undefined;
173
+ errorCode?: "ERR_CODEMOD_PROTECTED" | undefined;
147
174
  filesChanged?: number | undefined;
175
+ modifiedFiles?: string[] | undefined;
176
+ protectedFiles?: ProtectedCodemodFile[] | undefined;
177
+ declinedCandidates?: {
178
+ file: string;
179
+ location?: string;
180
+ reason: string;
181
+ }[] | undefined;
148
182
  transformsApplied?: number | undefined;
149
183
  errors?: {
150
184
  file: string;
@@ -95,6 +95,16 @@
95
95
  * @property {RegistryCompositionSummary} data
96
96
  */
97
97
 
98
+ /**
99
+ * A required codemod change blocked by effective working-tree protection.
100
+ * @typedef {object} ProtectedCodemodFile
101
+ * @property {string} file Project-relative path.
102
+ * @property {string[]} codemods Codemods that still require a change.
103
+ * @property {string[]} reasons Protection categories (generated, vendored, ignored, and hard boundaries).
104
+ * @property {string[]} declarations Effective declarations that protect the file.
105
+ * @property {string[]} commands Exact regeneration commands found in generated headers, when present.
106
+ */
107
+
98
108
  /**
99
109
  * astryx --json upgrade [--apply]
100
110
  * @typedef {object} UpgradeRunResponse
@@ -107,7 +117,12 @@
107
117
  * @property {boolean} data.agentDocsRefreshed
108
118
  * @property {AgentDocsSummary} data.agentDocs
109
119
  * @property {RegistryCompositionSummary} [data.registryCompositions]
120
+ * @property {boolean} [data.complete] False when protected required changes remain.
121
+ * @property {'ERR_CODEMOD_PROTECTED'} [data.errorCode] Stable incomplete-result code when complete is false.
110
122
  * @property {number} [data.filesChanged] Total files changed across core + integration codemods (apply mode).
123
+ * @property {string[]} [data.modifiedFiles] Project-relative files changed or previewed.
124
+ * @property {ProtectedCodemodFile[]} [data.protectedFiles] Protected files that still require a codemod change after regeneration.
125
+ * @property {Array<{file: string, location?: string, reason: string}>} [data.declinedCandidates] Candidates left unchanged because proof was insufficient.
111
126
  * @property {number} [data.transformsApplied] Total transforms that reported a change.
112
127
  * @property {Array<{file: string, codemod: string, error: string}>} [data.errors] Per-codemod errors, when any codemod failed.
113
128
  */
@@ -21,6 +21,40 @@ afterEach(() => {
21
21
  fs.rmSync(tmpDir, {recursive: true, force: true});
22
22
  });
23
23
 
24
+ describe('runCodemods — ordered dry-run state', () => {
25
+ it('feeds each previewed code transform into the next transform', async () => {
26
+ const srcDir = path.join(tmpDir, 'src');
27
+ fs.mkdirSync(srcDir);
28
+ fs.writeFileSync(path.join(srcDir, 'a.ts'), 'const value = "a";\n');
29
+ const transforms = [
30
+ {
31
+ name: 'a-to-b',
32
+ meta: {title: 'a to b'},
33
+ transform: file => file.source.replace('"a"', '"b"'),
34
+ },
35
+ {
36
+ name: 'b-to-c',
37
+ meta: {title: 'b to c'},
38
+ transform: file => file.source.replace('"b"', '"c"'),
39
+ },
40
+ ];
41
+
42
+ const preview = await runCodemods([{version: '1.0.0', transforms}], {
43
+ apply: false,
44
+ path: srcDir,
45
+ root: tmpDir,
46
+ silent: true,
47
+ });
48
+
49
+ expect(preview.totalFilesChanged).toBe(2);
50
+ expect(preview.changedFiles).toEqual([
51
+ path.join(srcDir, 'a.ts'),
52
+ path.join(srcDir, 'a.ts'),
53
+ ]);
54
+ expect(fs.readFileSync(path.join(srcDir, 'a.ts'), 'utf8')).toContain('"a"');
55
+ });
56
+ });
57
+
24
58
  describe('runCodemods — unified config codemod path', () => {
25
59
  it('routes a core config codemod through the (file, api) runner to edit astryx.config.*', async () => {
26
60
  fs.writeFileSync(
@@ -195,17 +229,219 @@ describe('runCodemods — unified config codemod path', () => {
195
229
  });
196
230
  });
197
231
 
198
- describe('findSourceFiles — scan boundaries (symlink + build dirs)', () => {
232
+ describe('runCodemods — project plans honor existing-file protection', () => {
233
+ it('applies new and owned writes while blocking protected replacements and deletes', async () => {
234
+ fs.writeFileSync(
235
+ path.join(tmpDir, '.gitattributes'),
236
+ 'generated/** linguist-generated=true\n',
237
+ );
238
+ fs.mkdirSync(path.join(tmpDir, 'generated'), {recursive: true});
239
+ fs.writeFileSync(path.join(tmpDir, 'generated', 'replace.ts'), 'old\n');
240
+ fs.writeFileSync(path.join(tmpDir, 'generated', 'remove.ts'), 'old\n');
241
+ fs.writeFileSync(path.join(tmpDir, 'owned.ts'), 'old\n');
242
+
243
+ const manifests = [
244
+ {
245
+ version: '1.0.0',
246
+ transforms: [
247
+ {
248
+ name: 'project-plan',
249
+ meta: {title: 'project plan', codemodType: 'project'},
250
+ transform: async root => ({
251
+ writes: [
252
+ {
253
+ path: path.join(root, 'generated', 'replace.ts'),
254
+ contents: 'new\n',
255
+ },
256
+ {path: path.join(root, 'owned.ts'), contents: 'new\n'},
257
+ {path: path.join(root, 'created.ts'), contents: 'new\n'},
258
+ ],
259
+ deletes: [path.join(root, 'generated', 'remove.ts')],
260
+ problems: [],
261
+ }),
262
+ },
263
+ ],
264
+ },
265
+ ];
266
+
267
+ const result = await runCodemods(manifests, {
268
+ apply: true,
269
+ path: './missing',
270
+ root: tmpDir,
271
+ silent: true,
272
+ });
273
+
274
+ expect(
275
+ fs.readFileSync(path.join(tmpDir, 'generated', 'replace.ts'), 'utf8'),
276
+ ).toBe('old\n');
277
+ expect(fs.existsSync(path.join(tmpDir, 'generated', 'remove.ts'))).toBe(
278
+ true,
279
+ );
280
+ expect(fs.readFileSync(path.join(tmpDir, 'owned.ts'), 'utf8')).toBe(
281
+ 'new\n',
282
+ );
283
+ expect(fs.readFileSync(path.join(tmpDir, 'created.ts'), 'utf8')).toBe(
284
+ 'new\n',
285
+ );
286
+ expect(result.totalFilesChanged).toBe(2);
287
+ expect(result.protectedFiles.map(item => item.file).sort()).toEqual([
288
+ 'generated/remove.ts',
289
+ 'generated/replace.ts',
290
+ ]);
291
+ });
292
+
293
+ it('blocks an existing file protected by a declaration in the same plan', async () => {
294
+ fs.mkdirSync(path.join(tmpDir, 'src'), {recursive: true});
295
+ fs.writeFileSync(path.join(tmpDir, 'src', 'out.ts'), 'old\n');
296
+ const manifests = [
297
+ {
298
+ version: '1.0.0',
299
+ transforms: [
300
+ {
301
+ name: 'declare-and-replace',
302
+ meta: {title: 'declare and replace', codemodType: 'project'},
303
+ transform: async root => ({
304
+ writes: [
305
+ {
306
+ path: path.join(root, '.gitattributes'),
307
+ contents: 'src/out.ts linguist-generated\n',
308
+ },
309
+ {path: path.join(root, 'src', 'out.ts'), contents: 'new\n'},
310
+ ],
311
+ deletes: [],
312
+ problems: [],
313
+ }),
314
+ },
315
+ ],
316
+ },
317
+ ];
318
+
319
+ const result = await runCodemods(manifests, {
320
+ apply: true,
321
+ path: './missing',
322
+ root: tmpDir,
323
+ silent: true,
324
+ });
325
+
326
+ expect(fs.readFileSync(path.join(tmpDir, 'src', 'out.ts'), 'utf8')).toBe(
327
+ 'old\n',
328
+ );
329
+ expect(fs.readFileSync(path.join(tmpDir, '.gitattributes'), 'utf8')).toBe(
330
+ 'src/out.ts linguist-generated\n',
331
+ );
332
+ expect(result.protectedFiles).toEqual([
333
+ expect.objectContaining({
334
+ file: 'src/out.ts',
335
+ declaration: 'linguist-generated in .gitattributes:1',
336
+ }),
337
+ ]);
338
+ });
339
+
340
+ it('does not apply rules from a blocked staged declaration', async () => {
341
+ fs.writeFileSync(
342
+ path.join(tmpDir, '.gitattributes'),
343
+ '.gitattributes linguist-generated\n',
344
+ );
345
+ fs.mkdirSync(path.join(tmpDir, 'src'), {recursive: true});
346
+ fs.writeFileSync(path.join(tmpDir, 'src', 'out.ts'), 'old\n');
347
+ const result = await runCodemods(
348
+ [
349
+ {
350
+ version: '1.0.0',
351
+ transforms: [
352
+ {
353
+ name: 'blocked-declaration',
354
+ meta: {title: 'blocked declaration', codemodType: 'project'},
355
+ transform: async root => ({
356
+ writes: [
357
+ {
358
+ path: path.join(root, '.gitattributes'),
359
+ contents: 'src/out.ts linguist-generated\n',
360
+ },
361
+ {path: path.join(root, 'src', 'out.ts'), contents: 'new\n'},
362
+ ],
363
+ deletes: [],
364
+ problems: [],
365
+ }),
366
+ },
367
+ ],
368
+ },
369
+ ],
370
+ {apply: true, path: './missing', root: tmpDir, silent: true},
371
+ );
372
+
373
+ expect(fs.readFileSync(path.join(tmpDir, 'src', 'out.ts'), 'utf8')).toBe(
374
+ 'new\n',
375
+ );
376
+ expect(result.protectedFiles).toEqual([
377
+ expect.objectContaining({file: '.gitattributes'}),
378
+ ]);
379
+ });
380
+
381
+ it('refreshes declarations written by an earlier project codemod before later writes', async () => {
382
+ fs.mkdirSync(path.join(tmpDir, 'src'), {recursive: true});
383
+ fs.writeFileSync(path.join(tmpDir, 'src', 'out.ts'), 'const foo = 1;\n');
384
+ const manifests = [
385
+ {
386
+ version: '1.0.0',
387
+ transforms: [
388
+ {
389
+ name: 'declare-generated',
390
+ meta: {title: 'declare generated', codemodType: 'project'},
391
+ transform: async root => ({
392
+ writes: [
393
+ {
394
+ path: path.join(root, '.gitattributes'),
395
+ contents: 'src/out.ts linguist-generated=true\n',
396
+ },
397
+ ],
398
+ deletes: [],
399
+ problems: [],
400
+ }),
401
+ },
402
+ {
403
+ name: 'rename-after-declaration',
404
+ meta: {title: 'rename after declaration'},
405
+ transform: file => file.source.replace('foo', 'bar'),
406
+ },
407
+ ],
408
+ },
409
+ ];
410
+
411
+ const result = await runCodemods(manifests, {
412
+ apply: true,
413
+ path: path.join(tmpDir, 'src'),
414
+ root: tmpDir,
415
+ silent: true,
416
+ });
417
+
418
+ expect(
419
+ fs.readFileSync(path.join(tmpDir, 'src', 'out.ts'), 'utf8'),
420
+ ).toContain('foo');
421
+ expect(result.protectedFiles).toEqual([
422
+ expect.objectContaining({
423
+ file: 'src/out.ts',
424
+ declaration: 'linguist-generated in .gitattributes:1',
425
+ }),
426
+ ]);
427
+ });
428
+ });
429
+
430
+ describe('findSourceFiles — scan boundaries (symlink + declared protection)', () => {
199
431
  const manifests = [
200
432
  {
201
433
  version: '0.1.3',
202
434
  transforms: [
203
- {name: 'p', meta: {title: 'p'}, transform: f => f.source.replace(/foo/g, 'bar')},
435
+ {
436
+ name: 'p',
437
+ meta: {title: 'p'},
438
+ transform: f => f.source.replace(/foo/g, 'bar'),
439
+ },
204
440
  ],
205
441
  },
206
442
  ];
207
443
 
208
- it('does not follow a symlinked file out of the scan tree', async () => {
444
+ it('reports a symlinked file without writing through it', async () => {
209
445
  const outside = path.join(tmpDir, 'outside');
210
446
  fs.mkdirSync(path.join(tmpDir, 'src'), {recursive: true});
211
447
  fs.mkdirSync(outside, {recursive: true});
@@ -214,13 +450,45 @@ describe('findSourceFiles — scan boundaries (symlink + build dirs)', () => {
214
450
  fs.writeFileSync(path.join(tmpDir, 'src', 'real.ts'), 'const foo = 2;\n');
215
451
  fs.symlinkSync(secret, path.join(tmpDir, 'src', 'link.ts'));
216
452
 
217
- const r = await runCodemods(manifests, {apply: true, path: './src', silent: true});
453
+ const r = await runCodemods(manifests, {
454
+ apply: true,
455
+ path: './src',
456
+ silent: true,
457
+ });
218
458
  // the symlink target (outside the tree) must be untouched
219
459
  expect(fs.readFileSync(secret, 'utf-8')).toBe('const foo = 1;\n');
220
460
  expect(r.writtenFiles.map(f => path.basename(f))).toEqual(['real.ts']);
461
+ expect(r.protectedFiles).toEqual([
462
+ expect.objectContaining({file: 'src/link.ts', reason: 'symlink'}),
463
+ ]);
221
464
  });
222
465
 
223
- it('does not scan generated-output dirs (dist/build/out)', async () => {
466
+ it('reports matching descendants of a symlinked directory without writing through it', async () => {
467
+ const targetDir = path.join(tmpDir, 'target');
468
+ const srcDir = path.join(tmpDir, 'src');
469
+ fs.mkdirSync(targetDir, {recursive: true});
470
+ fs.mkdirSync(srcDir, {recursive: true});
471
+ const target = path.join(targetDir, 'nested.ts');
472
+ fs.writeFileSync(target, 'const foo = 1;\n');
473
+ fs.symlinkSync(targetDir, path.join(srcDir, 'linked'));
474
+
475
+ const result = await runCodemods(manifests, {
476
+ apply: true,
477
+ path: srcDir,
478
+ root: tmpDir,
479
+ silent: true,
480
+ });
481
+
482
+ expect(fs.readFileSync(target, 'utf8')).toContain('foo');
483
+ expect(result.protectedFiles).toEqual([
484
+ expect.objectContaining({
485
+ file: 'src/linked/nested.ts',
486
+ reason: 'symlink',
487
+ }),
488
+ ]);
489
+ });
490
+
491
+ it('does not infer protection from generated-looking directory names', async () => {
224
492
  fs.mkdirSync(path.join(tmpDir, 'src'), {recursive: true});
225
493
  fs.mkdirSync(path.join(tmpDir, 'dist'), {recursive: true});
226
494
  fs.mkdirSync(path.join(tmpDir, 'build'), {recursive: true});
@@ -228,8 +496,62 @@ describe('findSourceFiles — scan boundaries (symlink + build dirs)', () => {
228
496
  fs.writeFileSync(path.join(tmpDir, 'dist', 'b.js'), 'const foo = 2;\n');
229
497
  fs.writeFileSync(path.join(tmpDir, 'build', 'c.js'), 'const foo = 3;\n');
230
498
 
231
- const r = await runCodemods(manifests, {apply: true, path: '.', silent: true});
232
- expect(r.writtenFiles.map(f => path.basename(f))).toEqual(['a.ts']);
233
- expect(fs.readFileSync(path.join(tmpDir, 'dist', 'b.js'), 'utf-8')).toContain('foo');
499
+ const r = await runCodemods(manifests, {
500
+ apply: true,
501
+ path: '.',
502
+ silent: true,
503
+ });
504
+ expect(r.writtenFiles.map(f => path.basename(f)).sort()).toEqual([
505
+ 'a.ts',
506
+ 'b.js',
507
+ 'c.js',
508
+ ]);
509
+ });
510
+
511
+ it('changes owned files but reports generated files without writing them', async () => {
512
+ fs.mkdirSync(path.join(tmpDir, 'src'), {recursive: true});
513
+ fs.mkdirSync(path.join(tmpDir, 'dist'), {recursive: true});
514
+ fs.writeFileSync(
515
+ path.join(tmpDir, '.gitattributes'),
516
+ 'dist/** linguist-generated=true\n',
517
+ );
518
+ fs.writeFileSync(path.join(tmpDir, 'src', 'owned.ts'), 'const foo = 1;\n');
519
+ fs.writeFileSync(
520
+ path.join(tmpDir, 'dist', 'output.js'),
521
+ 'const foo = 2;\n',
522
+ );
523
+
524
+ const r = await runCodemods(manifests, {
525
+ apply: true,
526
+ path: '.',
527
+ silent: true,
528
+ });
529
+
530
+ expect(
531
+ fs.readFileSync(path.join(tmpDir, 'src', 'owned.ts'), 'utf-8'),
532
+ ).toContain('bar');
533
+ expect(
534
+ fs.readFileSync(path.join(tmpDir, 'dist', 'output.js'), 'utf-8'),
535
+ ).toContain('foo');
536
+ expect(r.protectedFiles).toEqual([
537
+ expect.objectContaining({
538
+ file: 'dist/output.js',
539
+ reason: 'generated',
540
+ declaration: 'linguist-generated in .gitattributes:1',
541
+ }),
542
+ ]);
543
+ });
544
+
545
+ it('fails closed before an owned write when a declaration is malformed', async () => {
546
+ fs.mkdirSync(path.join(tmpDir, 'src'), {recursive: true});
547
+ fs.writeFileSync(path.join(tmpDir, 'src', 'owned.ts'), 'const foo = 1;\n');
548
+ fs.writeFileSync(path.join(tmpDir, '.hgignore'), 'syntax: regexp\n[bad\n');
549
+
550
+ await expect(
551
+ runCodemods(manifests, {apply: true, path: '.', silent: true}),
552
+ ).rejects.toThrow(/file-protection source \.hgignore/);
553
+ expect(
554
+ fs.readFileSync(path.join(tmpDir, 'src', 'owned.ts'), 'utf-8'),
555
+ ).toContain('foo');
234
556
  });
235
557
  });
@@ -24,6 +24,7 @@ import {
24
24
  runCodeCodemod,
25
25
  runConfigCodemod,
26
26
  } from './run-codemod.mjs';
27
+ import {createFileProtectionResolver} from '../../foundation/fs/file-protection.mjs';
27
28
 
28
29
  /**
29
30
  * Run file-based integration codemods, version-ordered. Config codemods run
@@ -40,18 +41,40 @@ import {
40
41
  * @param {Set<string>} [options.skipCodemods] codemod ids to exclude
41
42
  * @param {import('../../authoring/codemod/type').JscodeshiftFactory} options.jscodeshift
42
43
  * @param {boolean} [options.silent]
43
- * @returns {{totalFilesChanged: number, totalTransformsApplied: number, writtenFiles: string[], errors: Array<{file: string, codemod: string, error: string}>, skippedOptional: Array<import('../../authoring/codemod/type').CodemodEntry>}}
44
+ * @param {string} [options.root]
45
+ * @param {Map<string, string>} [options.contents] staged bytes from earlier dry-run phases
46
+ * @param {{root: string, classify: (file: string) => import('../../foundation/fs/file-protection.mjs').FileProtection[]}} [options.protection]
47
+ * @returns {{totalFilesChanged: number, totalTransformsApplied: number, changedFiles: string[], writtenFiles: string[], stagedContents: Map<string, string>, protectedFiles: import('../../authoring/codemod/type').CodemodRunResult['protectedFiles'], errors: Array<{file: string, codemod: string, error: string}>, skippedOptional: Array<import('../../authoring/codemod/type').CodemodEntry>}}
44
48
  */
45
49
  export function runIntegrationCodemods(
46
50
  versionGroups,
47
- {apply, path: srcPath, codemod, skipCodemods, jscodeshift, silent = false},
51
+ {
52
+ apply,
53
+ path: srcPath,
54
+ codemod,
55
+ skipCodemods,
56
+ jscodeshift,
57
+ silent = false,
58
+ root = process.cwd(),
59
+ protection: providedProtection,
60
+ contents: providedContents,
61
+ },
48
62
  ) {
49
63
  const log = makeLog(silent);
64
+ let protection =
65
+ providedProtection ?? createFileProtectionResolver(path.resolve(root));
66
+ let protectionWriteCount = 0;
67
+ /** In-memory pipeline state keeps ordered dry-runs equivalent to apply. */
68
+ const virtualContents = new Map(providedContents ?? []);
50
69
 
51
70
  let totalFilesChanged = 0;
52
71
  let totalTransformsApplied = 0;
53
72
  /** @type {string[]} */
73
+ const changedFiles = [];
74
+ /** @type {string[]} */
54
75
  const writtenFiles = [];
76
+ /** @type {import('../../authoring/codemod/type').CodemodRunResult['protectedFiles']} */
77
+ const protectedFiles = [];
55
78
  /** @type {Array<{file: string, codemod: string, error: string}>} */
56
79
  const errors = [];
57
80
  /** @type {Array<import('../../authoring/codemod/type').CodemodEntry>} */
@@ -79,11 +102,24 @@ export function runIntegrationCodemods(
79
102
 
80
103
  // Config codemods first.
81
104
  for (const entry of configEntries) {
105
+ if (apply && writtenFiles.length !== protectionWriteCount) {
106
+ protection = createFileProtectionResolver(path.resolve(root));
107
+ protectionWriteCount = writtenFiles.length;
108
+ }
82
109
  log.info(` ${entry.codemod.title} (v${entry.version}, ${entry.package})`);
83
- const r = runConfigCodemod(entry, {apply, log, jscodeshift});
110
+ const r = runConfigCodemod(entry, {
111
+ apply,
112
+ log,
113
+ jscodeshift,
114
+ root,
115
+ protection,
116
+ contents: virtualContents,
117
+ });
84
118
  totalFilesChanged += r.filesChanged;
85
119
  totalTransformsApplied += r.filesChanged;
120
+ changedFiles.push(...r.changedFiles);
86
121
  writtenFiles.push(...r.writtenFiles);
122
+ protectedFiles.push(...r.protectedFiles);
87
123
  errors.push(...r.errors);
88
124
  }
89
125
 
@@ -94,13 +130,26 @@ export function runIntegrationCodemods(
94
130
  ? findSourceFiles(resolvedPath)
95
131
  : [];
96
132
  for (const entry of codeEntries) {
133
+ if (apply && writtenFiles.length !== protectionWriteCount) {
134
+ protection = createFileProtectionResolver(path.resolve(root));
135
+ protectionWriteCount = writtenFiles.length;
136
+ }
97
137
  log.info(
98
138
  ` ${entry.codemod.title} (v${entry.version}, ${entry.package})`,
99
139
  );
100
- const r = runCodeCodemod(entry, files, {apply, log, jscodeshift});
140
+ const r = runCodeCodemod(entry, files, {
141
+ apply,
142
+ log,
143
+ jscodeshift,
144
+ root,
145
+ protection,
146
+ contents: virtualContents,
147
+ });
101
148
  totalFilesChanged += r.filesChanged;
102
149
  totalTransformsApplied += r.filesChanged;
150
+ changedFiles.push(...r.changedFiles);
103
151
  writtenFiles.push(...r.writtenFiles);
152
+ protectedFiles.push(...r.protectedFiles);
104
153
  errors.push(...r.errors);
105
154
  }
106
155
  }
@@ -108,7 +157,10 @@ export function runIntegrationCodemods(
108
157
  return {
109
158
  totalFilesChanged,
110
159
  totalTransformsApplied,
160
+ changedFiles,
111
161
  writtenFiles,
162
+ stagedContents: virtualContents,
163
+ protectedFiles,
112
164
  errors,
113
165
  skippedOptional,
114
166
  };