@astryxdesign/cli 0.6.3-canary.ddb63c3 → 0.6.3-canary.df1837b

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 (34) 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/upgrade.doc.mjs +2 -1
  24. package/clients/cli/commands/upgrade.file-protection.test.mjs +228 -0
  25. package/clients/cli/commands/upgrade.mjs +3 -0
  26. package/foundation/fs/file-protection.d.mts +33 -0
  27. package/foundation/fs/file-protection.mjs +825 -0
  28. package/foundation/fs/file-protection.test.mjs +250 -0
  29. package/foundation/response/error-codes.d.mts +3 -1
  30. package/foundation/response/error-codes.d.ts +2 -0
  31. package/foundation/response/error-codes.doc.mjs +10 -0
  32. package/foundation/response/error-codes.mjs +6 -0
  33. package/foundation/response/error-codes.test.mjs +55 -11
  34. package/package.json +10 -9
package/README.md CHANGED
@@ -189,6 +189,8 @@ if (isError(result)) {
189
189
  | `ERR_UNKNOWN_FEATURE` | An unrecognized `--features` value was passed to init. |
190
190
  | `ERR_UNKNOWN_CODEMOD` | A `--codemod` value did not match any registered codemod (upgrade). |
191
191
  | `ERR_CODEMOD_FAILED` | One or more codemods failed during an upgrade run. |
192
+ | `ERR_CODEMOD_PROTECTED` | A required codemod change remains blocked by a protected consumer file. |
193
+ | `ERR_CODEMOD_PROTECTION_SOURCE` | A working-tree protection declaration could not be read or parsed. |
192
194
  | `ERR_NOT_FOUND` | A generic discover/lookup query matched nothing in any package. |
193
195
  | `ERR_NO_DOC` | A component exists but has no typed `.doc.mjs` file. |
194
196
  | `ERR_NO_SHOWCASE` | No showcase exists for the requested component. |
@@ -104,19 +104,27 @@ export function ensureCodemodDeps({ installDeps }?: {
104
104
  * Run the CORE registry codemods. Runs BEFORE the config is loaded so a core
105
105
  * CONFIG codemod can repair a config the strict loader would otherwise reject.
106
106
  * @param {CoreVersionManifest[]} versionManifests
107
- * @param {{apply: boolean, path: string, codemod?: string, skipCodemods: Set<string>, root?: string}} options
107
+ * @param {{apply: boolean, path: string, codemod?: string, skipCodemods: Set<string>, root?: string, protection?: {root: string, classify: (file: string) => import('../../foundation/fs/file-protection.mjs').FileProtection[]}, silent?: boolean}} options
108
108
  */
109
- export function runCoreCodemods(versionManifests: CoreVersionManifest[], { apply, path: srcPath, codemod, skipCodemods, root }: {
109
+ export function runCoreCodemods(versionManifests: CoreVersionManifest[], { apply, path: srcPath, codemod, skipCodemods, root, protection, silent }: {
110
110
  apply: boolean;
111
111
  path: string;
112
112
  codemod?: string;
113
113
  skipCodemods: Set<string>;
114
114
  root?: string;
115
+ protection?: {
116
+ root: string;
117
+ classify: (file: string) => import("../../foundation/fs/file-protection.mjs").FileProtection[];
118
+ };
119
+ silent?: boolean;
115
120
  }): Promise<{
116
121
  totalFilesChanged: number;
117
122
  totalTransformsApplied: number;
118
123
  totalValidationBlocked: number;
124
+ changedFiles: string[];
119
125
  writtenFiles: string[];
126
+ stagedContents: Map<string, string>;
127
+ protectedFiles: import("../../authoring/codemod/type").CodemodRunResult["protectedFiles"];
120
128
  errors: Array<{
121
129
  file: string;
122
130
  codemod: string;
@@ -180,20 +188,30 @@ export function selectIntegrationCodemodsFor(integrations: Array<import("../../f
180
188
  /**
181
189
  * Run the file-based INTEGRATION codemods (config codemods first, then code).
182
190
  * @param {Array<{version: string, codemods: import('../../authoring/codemod/type').CodemodEntry[]}>} versionGroups
183
- * @param {{apply: boolean, path: string, codemod?: string, skipCodemods: Set<string>}} options
191
+ * @param {{apply: boolean, path: string, codemod?: string, skipCodemods: Set<string>, root?: string, protection?: {root: string, classify: (file: string) => import('../../foundation/fs/file-protection.mjs').FileProtection[]}, contents?: Map<string, string>, silent?: boolean}} options
184
192
  */
185
193
  export function runIntegrationCodemodsStep(versionGroups: Array<{
186
194
  version: string;
187
195
  codemods: import("../../authoring/codemod/type").CodemodEntry[];
188
- }>, { apply, path: srcPath, codemod, skipCodemods }: {
196
+ }>, { apply, path: srcPath, codemod, skipCodemods, root, protection, contents, silent, }: {
189
197
  apply: boolean;
190
198
  path: string;
191
199
  codemod?: string;
192
200
  skipCodemods: Set<string>;
201
+ root?: string;
202
+ protection?: {
203
+ root: string;
204
+ classify: (file: string) => import("../../foundation/fs/file-protection.mjs").FileProtection[];
205
+ };
206
+ contents?: Map<string, string>;
207
+ silent?: boolean;
193
208
  }): Promise<{
194
209
  totalFilesChanged: number;
195
210
  totalTransformsApplied: number;
211
+ changedFiles: string[];
196
212
  writtenFiles: string[];
213
+ stagedContents: Map<string, string>;
214
+ protectedFiles: import("../../authoring/codemod/type").CodemodRunResult["protectedFiles"];
197
215
  errors: Array<{
198
216
  file: string;
199
217
  codemod: string;
@@ -329,19 +329,20 @@ export async function ensureCodemodDeps({installDeps} = {}) {
329
329
  * Run the CORE registry codemods. Runs BEFORE the config is loaded so a core
330
330
  * CONFIG codemod can repair a config the strict loader would otherwise reject.
331
331
  * @param {CoreVersionManifest[]} versionManifests
332
- * @param {{apply: boolean, path: string, codemod?: string, skipCodemods: Set<string>, root?: string}} options
332
+ * @param {{apply: boolean, path: string, codemod?: string, skipCodemods: Set<string>, root?: string, protection?: {root: string, classify: (file: string) => import('../../foundation/fs/file-protection.mjs').FileProtection[]}, silent?: boolean}} options
333
333
  */
334
334
  export async function runCoreCodemods(
335
335
  versionManifests,
336
- {apply, path: srcPath, codemod, skipCodemods, root},
336
+ {apply, path: srcPath, codemod, skipCodemods, root, protection, silent},
337
337
  ) {
338
338
  return runCodemods(versionManifests, {
339
339
  apply,
340
340
  path: srcPath,
341
341
  codemod,
342
342
  skipCodemods,
343
- silent: logger.silent,
343
+ silent: silent ?? logger.silent,
344
344
  root,
345
+ protection,
345
346
  });
346
347
  }
347
348
 
@@ -479,11 +480,20 @@ export async function selectIntegrationCodemodsFor(integrations, from, to) {
479
480
  /**
480
481
  * Run the file-based INTEGRATION codemods (config codemods first, then code).
481
482
  * @param {Array<{version: string, codemods: import('../../authoring/codemod/type').CodemodEntry[]}>} versionGroups
482
- * @param {{apply: boolean, path: string, codemod?: string, skipCodemods: Set<string>}} options
483
+ * @param {{apply: boolean, path: string, codemod?: string, skipCodemods: Set<string>, root?: string, protection?: {root: string, classify: (file: string) => import('../../foundation/fs/file-protection.mjs').FileProtection[]}, contents?: Map<string, string>, silent?: boolean}} options
483
484
  */
484
485
  export async function runIntegrationCodemodsStep(
485
486
  versionGroups,
486
- {apply, path: srcPath, codemod, skipCodemods},
487
+ {
488
+ apply,
489
+ path: srcPath,
490
+ codemod,
491
+ skipCodemods,
492
+ root,
493
+ protection,
494
+ contents,
495
+ silent,
496
+ },
487
497
  ) {
488
498
  const jscodeshift = (await import('jscodeshift')).default;
489
499
  return runIntegrationCodemods(versionGroups, {
@@ -492,6 +502,9 @@ export async function runIntegrationCodemodsStep(
492
502
  codemod,
493
503
  skipCodemods,
494
504
  jscodeshift,
495
- silent: logger.silent,
505
+ silent: silent ?? logger.silent,
506
+ root,
507
+ protection,
508
+ contents,
496
509
  });
497
510
  }
@@ -17,6 +17,7 @@
17
17
  * integration; execution errors abort before the agent-doc write.
18
18
  */
19
19
 
20
+ import * as fs from 'node:fs';
20
21
  import * as path from 'node:path';
21
22
  import {
22
23
  detectInstalledTargetVersion,
@@ -47,7 +48,58 @@ import {getCliInvocation} from '../../../foundation/env/package-manager.mjs';
47
48
  import {ERROR_CODES} from '../../../foundation/response/error-codes.mjs';
48
49
  import {AstryxError} from '../../error.mjs';
49
50
  import {logger} from '../../logger.mjs';
50
- import {assertWithin, PathSafetyError} from '../../../foundation/fs/path-safety.mjs';
51
+ import {
52
+ assertWithin,
53
+ PathSafetyError,
54
+ } from '../../../foundation/fs/path-safety.mjs';
55
+ import {createFileProtectionResolver} from '../../../foundation/fs/file-protection.mjs';
56
+
57
+ /**
58
+ * Collapse per-codemod protection hits into one stable row per file.
59
+ * @param {Array<{file: string, codemod: string, reason: string, declaration: string, generated: boolean, command?: string}>} rows
60
+ */
61
+ function summarizeProtectedFiles(rows) {
62
+ const byFile = new Map();
63
+ for (const row of rows) {
64
+ const current = byFile.get(row.file) ?? {
65
+ file: row.file,
66
+ codemods: new Set(),
67
+ reasons: new Set(),
68
+ declarations: new Set(),
69
+ commands: new Set(),
70
+ };
71
+ current.codemods.add(row.codemod);
72
+ current.reasons.add(row.reason);
73
+ current.declarations.add(row.declaration);
74
+ if (row.command) current.commands.add(row.command);
75
+ byFile.set(row.file, current);
76
+ }
77
+ return [...byFile.values()]
78
+ .map(item => ({
79
+ file: item.file,
80
+ codemods: [...item.codemods].sort(),
81
+ reasons: [...item.reasons].sort(),
82
+ declarations: [...item.declarations].sort(),
83
+ commands: [...item.commands].sort(),
84
+ }))
85
+ .sort((a, b) => a.file.localeCompare(b.file));
86
+ }
87
+
88
+ /** @param {string} cwd */
89
+ function loadFileProtection(cwd) {
90
+ try {
91
+ return createFileProtectionResolver(cwd);
92
+ } catch (err) {
93
+ const message = err instanceof Error ? err.message : String(err);
94
+ logger.error(message);
95
+ logger.log('Upgrade failed\n');
96
+ throw new AstryxError(
97
+ message,
98
+ undefined,
99
+ ERROR_CODES.ERR_CODEMOD_PROTECTION_SOURCE,
100
+ );
101
+ }
102
+ }
51
103
 
52
104
  /**
53
105
  * Run the upgrade pipeline for a validated, non-list invocation. Returns the
@@ -75,7 +127,11 @@ export async function run(options = {}, {cwd = process.cwd()} = {}) {
75
127
  if (err instanceof PathSafetyError) {
76
128
  logger.error(err.message);
77
129
  logger.log('Aborted\n');
78
- throw new AstryxError(err.message, undefined, ERROR_CODES.ERR_PATH_TRAVERSAL);
130
+ throw new AstryxError(
131
+ err.message,
132
+ undefined,
133
+ ERROR_CODES.ERR_PATH_TRAVERSAL,
134
+ );
79
135
  }
80
136
  throw err;
81
137
  }
@@ -133,7 +189,10 @@ export async function run(options = {}, {cwd = process.cwd()} = {}) {
133
189
  });
134
190
  }
135
191
 
136
- const versionManifests = await getCoreVersionManifests(currentVersion, targetVersion);
192
+ const versionManifests = await getCoreVersionManifests(
193
+ currentVersion,
194
+ targetVersion,
195
+ );
137
196
 
138
197
  const coreConfigCodemodNames = [];
139
198
  for (const {transforms} of versionManifests) {
@@ -157,6 +216,11 @@ export async function run(options = {}, {cwd = process.cwd()} = {}) {
157
216
  }
158
217
  }
159
218
 
219
+ // Read and parse all working-tree protection declarations before any
220
+ // dependency installation or codemod write. Core and integration runners
221
+ // share this exact snapshot.
222
+ let protection = loadFileProtection(cwd);
223
+
160
224
  const ready = await ensureCodemodDeps({installDeps: options.installDeps});
161
225
  if (!ready) {
162
226
  const msg = 'jscodeshift is required but could not be installed.';
@@ -172,34 +236,81 @@ export async function run(options = {}, {cwd = process.cwd()} = {}) {
172
236
  codemod: options.codemod,
173
237
  skipCodemods,
174
238
  root: cwd,
239
+ protection,
175
240
  });
176
- const coreResult = codemodResult && 'totalFilesChanged' in codemodResult ? codemodResult : null;
241
+ const coreResult =
242
+ codemodResult && 'totalFilesChanged' in codemodResult
243
+ ? codemodResult
244
+ : null;
177
245
 
178
246
  /** @type {Array<import('../../../foundation/integrations/integrations.mjs').LoadedIntegration>} */
179
247
  let integrations;
180
248
  /** @type {import('../../../authoring/config/type').PostCodemodHook[]} */
181
249
  let postCodemodHooks;
182
250
  try {
183
- const projectContext = await loadProjectContext(cwd, options.integration ?? []);
251
+ const projectContext = await loadProjectContext(
252
+ cwd,
253
+ options.integration ?? [],
254
+ );
184
255
  postCodemodHooks = projectContext.postCodemodHooks;
185
256
  integrations = projectContext.integrations;
186
257
  } catch (err) {
187
258
  const configErr = /** @type {Error} */ (err);
259
+ const allProtected = coreResult?.protectedFiles ?? [];
260
+ if (allProtected.length > 0) {
261
+ const protectedFiles = summarizeProtectedFiles(allProtected);
262
+ logger.error(
263
+ `${ERROR_CODES.ERR_CODEMOD_PROTECTED}: protected codemod changes remain while loading the Astryx config.`,
264
+ );
265
+ for (const item of protectedFiles) {
266
+ logger.error(` ${item.file} — ${item.declarations.join('; ')}`);
267
+ }
268
+ logger.log('Upgrade incomplete: protected changes remain\n');
269
+ return {
270
+ type: 'upgrade.run',
271
+ data: {
272
+ from: currentVersion,
273
+ to: targetVersion,
274
+ codemods: totalTransforms,
275
+ integrations: [],
276
+ agentDocsRefreshed: false,
277
+ agentDocs: {
278
+ status: 'current',
279
+ installedVersion: targetVersion,
280
+ fromVersions: [],
281
+ files: [],
282
+ refreshed: false,
283
+ action: 'none',
284
+ },
285
+ filesChanged: coreResult?.totalFilesChanged ?? 0,
286
+ transformsApplied: coreResult?.totalTransformsApplied ?? 0,
287
+ modifiedFiles: uniqueFiles(coreResult?.changedFiles).map(file =>
288
+ path.relative(cwd, file).split(path.sep).join('/'),
289
+ ),
290
+ protectedFiles,
291
+ declinedCandidates: [],
292
+ complete: false,
293
+ errorCode: 'ERR_CODEMOD_PROTECTED',
294
+ errors: coreResult?.errors ?? [],
295
+ },
296
+ };
297
+ }
188
298
  // Graceful dry-run catch: a config that fails strict validation is expected
189
299
  // & fixable ONLY when dry-run AND a pending core config codemod previewed a
190
300
  // change (the codemod that would repair it).
191
- const codemodWouldFixConfig = hasCoreConfigCodemod && (coreResult?.totalFilesChanged ?? 0) > 0;
301
+ const codemodWouldFixConfig =
302
+ hasCoreConfigCodemod && (coreResult?.totalFilesChanged ?? 0) > 0;
192
303
  if (!apply && codemodWouldFixConfig) {
193
304
  // Lightweight inspection — no config/Project load (config is still broken
194
305
  // in dry-run; the codemod previewed a fix but did not write it).
195
306
  const inspection = inspectAgentDocs(cwd, targetVersion);
196
- return statusConfigFixable(
197
- {
198
- from: currentVersion,
199
- to: targetVersion,
200
- configError: configErr.message,
201
- configCodemods: coreConfigCodemodNames,
202
- agentDocs: /** @type {import('../upgrade.type.mjs').AgentDocsSummary} */ ({
307
+ return statusConfigFixable({
308
+ from: currentVersion,
309
+ to: targetVersion,
310
+ configError: configErr.message,
311
+ configCodemods: coreConfigCodemodNames,
312
+ agentDocs:
313
+ /** @type {import('../upgrade.type.mjs').AgentDocsSummary} */ ({
203
314
  status: inspection.status,
204
315
  installedVersion: targetVersion,
205
316
  fromVersions: inspection.blockVersions,
@@ -207,17 +318,22 @@ export async function run(options = {}, {cwd = process.cwd()} = {}) {
207
318
  refreshed: false,
208
319
  action: inspection.status === 'missing' ? 'nudge-init' : 'none',
209
320
  }),
210
- },
211
- );
321
+ });
212
322
  }
213
323
  // Genuine config error: abort.
214
324
  logger.error(configErr.message);
215
325
  logger.log('Aborted\n');
216
- throw new AstryxError(configErr.message, undefined, ERROR_CODES.ERR_INVALID_ARGUMENT);
326
+ throw new AstryxError(
327
+ configErr.message,
328
+ undefined,
329
+ ERROR_CODES.ERR_INVALID_ARGUMENT,
330
+ );
217
331
  }
218
332
 
219
333
  if (integrations.length > 0) {
220
- logger.log(`Integrations: ${integrations.map(i => i.name ?? i.__spec).join(', ')}`);
334
+ logger.log(
335
+ `Integrations: ${integrations.map(i => i.name ?? i.__spec).join(', ')}`,
336
+ );
221
337
  }
222
338
 
223
339
  // Non-blocking nudge for integration validation issues (suppressed for
@@ -229,7 +345,9 @@ export async function run(options = {}, {cwd = process.cwd()} = {}) {
229
345
  currentVersion,
230
346
  targetVersion,
231
347
  );
232
- const hasIntegrationCodemods = integrationVersionGroups.some(g => g.codemods.length > 0);
348
+ const hasIntegrationCodemods = integrationVersionGroups.some(
349
+ g => g.codemods.length > 0,
350
+ );
233
351
 
234
352
  for (const {codemods} of integrationVersionGroups) {
235
353
  for (const c of codemods) {
@@ -267,14 +385,14 @@ export async function run(options = {}, {cwd = process.cwd()} = {}) {
267
385
  }
268
386
 
269
387
  if (totalTransforms > 0) {
270
- logger.log(`${totalTransforms} codemod${totalTransforms === 1 ? '' : 's'} to run${apply ? '' : ' (dry run)'}`);
388
+ logger.log(
389
+ `${totalTransforms} codemod${totalTransforms === 1 ? '' : 's'} to run${apply ? '' : ' (dry run)'}`,
390
+ );
271
391
  } else {
272
392
  logger.log('No automatic codemods to run for this version range.');
273
393
  }
274
394
 
275
- /**
276
- * @type {{from: string, to: string, codemods: number, integrations: string[], agentDocsRefreshed: boolean, agentDocs: import('../upgrade.type.mjs').AgentDocsSummary, registryCompositions?: import('../upgrade.type.mjs').RegistryCompositionSummary, filesChanged?: number, transformsApplied?: number, errors?: Array<{file: string, codemod: string, error: string}>}}
277
- */
395
+ /** @type {import('../upgrade.type.mjs').UpgradeRunResponse['data']} */
278
396
  const receipt = {
279
397
  from: currentVersion,
280
398
  to: targetVersion,
@@ -282,79 +400,257 @@ export async function run(options = {}, {cwd = process.cwd()} = {}) {
282
400
  integrations: integrations.map(i => i.name ?? i.__spec),
283
401
  agentDocsRefreshed: false,
284
402
  agentDocs: /** @type {import('../upgrade.type.mjs').AgentDocsSummary} */ ({
285
- status: 'current', installedVersion: targetVersion,
286
- fromVersions: [], files: [], refreshed: false, action: 'none',
403
+ status: 'current',
404
+ installedVersion: targetVersion,
405
+ fromVersions: [],
406
+ files: [],
407
+ refreshed: false,
408
+ action: 'none',
287
409
  }),
288
410
  };
289
411
 
290
412
  let integrationResult = null;
291
413
  if (hasIntegrationCodemods) {
414
+ const coreStagedContents = coreResult?.stagedContents;
415
+ if (
416
+ (coreResult?.writtenFiles.length ?? 0) > 0 ||
417
+ (coreStagedContents?.size ?? 0) > 0
418
+ ) {
419
+ protection = createFileProtectionResolver(cwd, {
420
+ overrides: coreStagedContents,
421
+ });
422
+ }
292
423
  logger.log('Applying integration codemods...');
293
- integrationResult = await runIntegrationCodemodsStep(integrationVersionGroups, {
294
- apply,
295
- path: path_,
296
- codemod: options.codemod,
297
- skipCodemods,
298
- });
424
+ integrationResult = await runIntegrationCodemodsStep(
425
+ integrationVersionGroups,
426
+ {
427
+ apply,
428
+ path: path_,
429
+ codemod: options.codemod,
430
+ skipCodemods,
431
+ root: cwd,
432
+ protection,
433
+ contents: coreStagedContents,
434
+ },
435
+ );
299
436
  }
300
437
 
301
438
  const registryResult = await reconcileCompositions();
302
439
 
303
- const mergedFilesChanged = (coreResult?.totalFilesChanged ?? 0) + (integrationResult?.totalFilesChanged ?? 0);
304
- const mergedTransformsApplied = (coreResult?.totalTransformsApplied ?? 0) + (integrationResult?.totalTransformsApplied ?? 0);
305
- const mergedWrittenFiles = [
306
- ...(coreResult?.writtenFiles ?? []),
307
- ...(integrationResult?.writtenFiles ?? []),
440
+ const mergedFilesChanged =
441
+ (coreResult?.totalFilesChanged ?? 0) +
442
+ (integrationResult?.totalFilesChanged ?? 0);
443
+ const mergedTransformsApplied =
444
+ (coreResult?.totalTransformsApplied ?? 0) +
445
+ (integrationResult?.totalTransformsApplied ?? 0);
446
+ const mergedChangedFiles = [
447
+ ...(coreResult?.changedFiles ?? []),
448
+ ...(integrationResult?.changedFiles ?? []),
308
449
  ...(registryResult?.writtenFiles ?? []),
309
450
  ];
310
- const mergedErrors = [...(coreResult?.errors ?? []), ...(integrationResult?.errors ?? [])];
451
+ const mergedErrors = [
452
+ ...(coreResult?.errors ?? []),
453
+ ...(integrationResult?.errors ?? []),
454
+ ];
455
+ let finalErrors = mergedErrors;
456
+ /** @type {string|undefined} */
457
+ let hookFailure;
458
+ const initialProtected = [
459
+ ...(coreResult?.protectedFiles ?? []),
460
+ ...(integrationResult?.protectedFiles ?? []),
461
+ ];
311
462
  const registryFilesChanged = registryResult?.writtenFiles.length ?? 0;
463
+ const generatedChangeBlocked = initialProtected.some(item => item.generated);
464
+ const shouldRunHooks =
465
+ postCodemodHooks.length > 0 &&
466
+ (mergedFilesChanged > 0 ||
467
+ registryFilesChanged > 0 ||
468
+ generatedChangeBlocked);
469
+ /** @type {Map<string, Buffer>} */
470
+ const protectedBeforeHooks = new Map();
471
+ if (apply && shouldRunHooks) {
472
+ for (const item of initialProtected) {
473
+ const absolute = path.resolve(cwd, item.file);
474
+ const relative = path.relative(cwd, absolute);
475
+ if (
476
+ relative.startsWith(`..${path.sep}`) ||
477
+ relative === '..' ||
478
+ path.isAbsolute(relative)
479
+ )
480
+ continue;
481
+ try {
482
+ if (fs.lstatSync(absolute).isFile()) {
483
+ protectedBeforeHooks.set(absolute, fs.readFileSync(absolute));
484
+ }
485
+ } catch {
486
+ // A candidate can disappear between planning and regeneration.
487
+ }
488
+ }
489
+ }
490
+ /** @type {string[]} */
491
+ const hookModifiedFiles = [];
312
492
 
313
- if (postCodemodHooks.length > 0 && (mergedFilesChanged > 0 || registryFilesChanged > 0)) {
314
- const files = uniqueFiles(mergedWrittenFiles).map(file => path.relative(cwd, file));
493
+ if (shouldRunHooks) {
494
+ const files = uniqueFiles(mergedChangedFiles).map(file =>
495
+ path.relative(cwd, file),
496
+ );
315
497
  try {
316
- await runPostCodemodHooks(postCodemodHooks, {packageDir: cwd, files, apply: apply || false});
498
+ await runPostCodemodHooks(postCodemodHooks, {
499
+ packageDir: cwd,
500
+ files,
501
+ apply: apply || false,
502
+ });
317
503
  } catch (err) {
318
504
  const hookErr = /** @type {Error} */ (err);
319
505
  const msg = `Post-codemod hook failed: ${hookErr.message}`;
320
506
  logger.error(msg);
321
- logger.log('Upgrade failed\n');
322
- throw new AstryxError(msg, undefined, ERROR_CODES.ERR_CODEMOD_FAILED);
507
+ if (initialProtected.length === 0) {
508
+ logger.log('Upgrade failed\n');
509
+ throw new AstryxError(msg, undefined, ERROR_CODES.ERR_CODEMOD_FAILED);
510
+ }
511
+ hookFailure = msg;
512
+ finalErrors = [
513
+ ...mergedErrors,
514
+ {file: '.', codemod: 'post-codemod-hook', error: msg},
515
+ ];
516
+ logger.log('Regeneration failed; protected changes remain\n');
517
+ }
518
+ if (apply) {
519
+ for (const [file, before] of protectedBeforeHooks) {
520
+ try {
521
+ if (!before.equals(fs.readFileSync(file)))
522
+ hookModifiedFiles.push(file);
523
+ } catch {
524
+ hookModifiedFiles.push(file);
525
+ }
526
+ }
323
527
  }
324
528
  }
325
529
 
530
+ // A successful apply hook may have regenerated protected outputs. Rerun the
531
+ // selected codemods in preview mode against fresh bytes and fresh declarations
532
+ // to distinguish resolved outputs from changes that remain blocked.
533
+ let remainingProtected = initialProtected;
534
+ if (apply && shouldRunHooks && !hookFailure) {
535
+ const refreshedProtection = loadFileProtection(cwd);
536
+ const coreCheck = await runCoreCodemods(versionManifests, {
537
+ apply: false,
538
+ path: path_,
539
+ codemod: options.codemod,
540
+ skipCodemods,
541
+ root: cwd,
542
+ protection: refreshedProtection,
543
+ silent: true,
544
+ });
545
+ const checkedCore =
546
+ coreCheck && 'totalFilesChanged' in coreCheck ? coreCheck : null;
547
+ const recheckProtection =
548
+ (checkedCore?.stagedContents.size ?? 0) > 0
549
+ ? createFileProtectionResolver(cwd, {
550
+ overrides: checkedCore?.stagedContents,
551
+ })
552
+ : refreshedProtection;
553
+ const integrationCheck = hasIntegrationCodemods
554
+ ? await runIntegrationCodemodsStep(integrationVersionGroups, {
555
+ apply: false,
556
+ path: path_,
557
+ codemod: options.codemod,
558
+ skipCodemods,
559
+ root: cwd,
560
+ protection: recheckProtection,
561
+ contents: checkedCore?.stagedContents,
562
+ silent: true,
563
+ })
564
+ : null;
565
+ const recheckedProtected = [
566
+ ...(checkedCore?.protectedFiles ?? []),
567
+ ...(integrationCheck?.protectedFiles ?? []),
568
+ ];
569
+ const recheckedChangedFiles = [
570
+ ...(checkedCore?.changedFiles ?? []),
571
+ ...(integrationCheck?.changedFiles ?? []),
572
+ ].map(file => path.relative(cwd, file).split(path.sep).join('/'));
573
+ const initialByFile = new Map();
574
+ for (const item of initialProtected) {
575
+ const rows = initialByFile.get(item.file) ?? [];
576
+ rows.push(item);
577
+ initialByFile.set(item.file, rows);
578
+ }
579
+ // A hook that removes a protection marker without regenerating the bytes
580
+ // does not make the required change disappear. Retain the original
581
+ // declaration for that still-pending file.
582
+ for (const file of recheckedChangedFiles) {
583
+ recheckedProtected.push(...(initialByFile.get(file) ?? []));
584
+ }
585
+ remainingProtected = recheckedProtected;
586
+ finalErrors = [
587
+ ...mergedErrors,
588
+ ...(checkedCore?.errors ?? []),
589
+ ...(integrationCheck?.errors ?? []),
590
+ ];
591
+ }
592
+
593
+ const protectedFiles = summarizeProtectedFiles(remainingProtected);
326
594
  receipt.filesChanged = mergedFilesChanged;
327
595
  receipt.transformsApplied = mergedTransformsApplied;
328
- receipt.errors = mergedErrors;
596
+ receipt.modifiedFiles = uniqueFiles([
597
+ ...mergedChangedFiles,
598
+ ...hookModifiedFiles,
599
+ ]).map(file => path.relative(cwd, file).split(path.sep).join('/'));
600
+ receipt.protectedFiles = protectedFiles;
601
+ receipt.declinedCandidates = [];
602
+ receipt.complete = protectedFiles.length === 0;
603
+ if (!receipt.complete) {
604
+ receipt.errorCode = 'ERR_CODEMOD_PROTECTED';
605
+ }
606
+ receipt.errors = finalErrors;
329
607
  if (registryResult) receipt.registryCompositions = registryResult.summary;
330
608
 
331
- if (receipt.errors?.length > 0) {
609
+ if (protectedFiles.length > 0) {
610
+ logger.error(
611
+ `${ERROR_CODES.ERR_CODEMOD_PROTECTED}: ${protectedFiles.length} protected file${protectedFiles.length === 1 ? '' : 's'} still require${protectedFiles.length === 1 ? 's' : ''} a codemod change:`,
612
+ );
613
+ for (const item of protectedFiles) {
614
+ logger.error(` ${item.file} — ${item.declarations.join('; ')}`);
615
+ for (const command of item.commands) {
616
+ logger.log(` Regenerate with: ${command}`);
617
+ }
618
+ }
619
+ }
620
+
621
+ if (receipt.errors?.length > 0 && protectedFiles.length === 0) {
332
622
  const msg = `Upgrade completed with ${receipt.errors.length} codemod error${receipt.errors.length === 1 ? '' : 's'}.`;
333
623
  logger.log('Upgrade failed\n');
334
624
  throw new AstryxError(msg, undefined, ERROR_CODES.ERR_CODEMOD_FAILED);
335
625
  }
336
626
 
337
- // All codemods + hooks succeeded — render from final post-upgrade state.
338
- const agentDocsPlan = await prepareAgentDocsRefresh({
339
- cwd,
340
- installedVersion: targetVersion,
341
- apply,
342
- fresh: true,
343
- });
344
- const completedAgentDocs = apply
345
- ? applyAgentDocsRefresh(agentDocsPlan)
346
- : agentDocsPlan.summary;
347
- receipt.agentDocs = completedAgentDocs;
348
- receipt.agentDocsRefreshed = completedAgentDocs.refreshed;
627
+ // Only refresh managed docs after all required codemod changes are complete.
628
+ if (protectedFiles.length === 0) {
629
+ const agentDocsPlan = await prepareAgentDocsRefresh({
630
+ cwd,
631
+ installedVersion: targetVersion,
632
+ apply,
633
+ fresh: true,
634
+ });
635
+ const completedAgentDocs = apply
636
+ ? applyAgentDocsRefresh(agentDocsPlan)
637
+ : agentDocsPlan.summary;
638
+ receipt.agentDocs = completedAgentDocs;
639
+ receipt.agentDocsRefreshed = completedAgentDocs.refreshed;
640
+ }
349
641
 
350
642
  const registryOk = receipt.registryCompositions?.ok ?? true;
351
643
  logger.log(
352
- registryOk
353
- ? (apply ? 'Upgrade complete' : 'Dry run complete') + '\n'
354
- : 'Upgrade finished with unresolved registry items\n',
644
+ protectedFiles.length > 0
645
+ ? 'Upgrade incomplete: protected changes remain\n'
646
+ : registryOk
647
+ ? (apply ? 'Upgrade complete' : 'Dry run complete') + '\n'
648
+ : 'Upgrade finished with unresolved registry items\n',
355
649
  );
356
650
  return {
357
651
  type: 'upgrade.run',
358
- data: /** @type {import('../upgrade.type.mjs').UpgradeRunResponse['data']} */ (/** @type {unknown} */ (receipt)),
652
+ data: /** @type {import('../upgrade.type.mjs').UpgradeRunResponse['data']} */ (
653
+ /** @type {unknown} */ (receipt)
654
+ ),
359
655
  };
360
656
  }