@ontrails/trails 1.0.0-beta.14 → 1.0.0-beta.16

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 (197) hide show
  1. package/CHANGELOG.md +208 -0
  2. package/README.md +27 -0
  3. package/package.json +19 -8
  4. package/src/app.ts +17 -7
  5. package/src/clack.ts +1 -1
  6. package/src/cli.ts +304 -10
  7. package/src/completions.ts +240 -0
  8. package/src/load-app-mirror.ts +160 -0
  9. package/src/local-state-io.ts +153 -0
  10. package/src/project-writes.ts +320 -0
  11. package/src/run-collision.ts +125 -0
  12. package/src/run-completions-install.ts +179 -0
  13. package/src/run-example.ts +149 -0
  14. package/src/run-examples.ts +148 -0
  15. package/src/run-quiet.ts +75 -0
  16. package/src/run-trace.ts +273 -0
  17. package/src/run-warden.ts +39 -0
  18. package/src/run-watch.ts +432 -0
  19. package/src/scaffold-versions.generated.ts +12 -0
  20. package/src/trails/add-surface.ts +172 -0
  21. package/src/trails/add-trail.ts +73 -27
  22. package/src/trails/add-verify.ts +68 -23
  23. package/src/trails/completions-complete.ts +165 -0
  24. package/src/trails/completions.ts +47 -0
  25. package/src/trails/create-scaffold.ts +101 -35
  26. package/src/trails/create.ts +87 -74
  27. package/src/trails/dev-clean.ts +31 -22
  28. package/src/trails/dev-reset.ts +9 -3
  29. package/src/trails/dev-stats.ts +28 -20
  30. package/src/trails/dev-support.ts +109 -95
  31. package/src/trails/draft-promote.ts +351 -107
  32. package/src/trails/guide.ts +55 -38
  33. package/src/trails/load-app.ts +712 -38
  34. package/src/trails/root-dir.ts +21 -0
  35. package/src/trails/run-example.ts +482 -0
  36. package/src/trails/run-examples.ts +141 -0
  37. package/src/trails/run.ts +403 -0
  38. package/src/trails/survey.ts +517 -186
  39. package/src/trails/topo-activation.ts +385 -0
  40. package/src/trails/topo-compile.ts +55 -0
  41. package/src/trails/topo-history.ts +14 -11
  42. package/src/trails/topo-output-schemas.ts +175 -0
  43. package/src/trails/topo-pin.ts +25 -16
  44. package/src/trails/topo-read-support.ts +178 -238
  45. package/src/trails/topo-reports.ts +445 -63
  46. package/src/trails/topo-store-support.ts +67 -35
  47. package/src/trails/topo-support.ts +93 -147
  48. package/src/trails/topo-unpin.ts +17 -7
  49. package/src/trails/topo-verify.ts +19 -10
  50. package/src/trails/topo.ts +64 -31
  51. package/src/trails/warden-guide.ts +121 -0
  52. package/src/trails/warden.ts +137 -47
  53. package/src/versions.ts +28 -0
  54. package/.turbo/turbo-build.log +0 -1
  55. package/.turbo/turbo-lint.log +0 -3
  56. package/.turbo/turbo-typecheck.log +0 -1
  57. package/__tests__/examples.test.ts +0 -20
  58. package/dist/bin/trails.d.ts +0 -3
  59. package/dist/bin/trails.d.ts.map +0 -1
  60. package/dist/bin/trails.js +0 -4
  61. package/dist/bin/trails.js.map +0 -1
  62. package/dist/src/app.d.ts +0 -2
  63. package/dist/src/app.d.ts.map +0 -1
  64. package/dist/src/app.js +0 -22
  65. package/dist/src/app.js.map +0 -1
  66. package/dist/src/clack.d.ts +0 -9
  67. package/dist/src/clack.d.ts.map +0 -1
  68. package/dist/src/clack.js +0 -84
  69. package/dist/src/clack.js.map +0 -1
  70. package/dist/src/cli.d.ts +0 -2
  71. package/dist/src/cli.d.ts.map +0 -1
  72. package/dist/src/cli.js +0 -13
  73. package/dist/src/cli.js.map +0 -1
  74. package/dist/src/trails/add-surface.d.ts +0 -13
  75. package/dist/src/trails/add-surface.d.ts.map +0 -1
  76. package/dist/src/trails/add-surface.js +0 -88
  77. package/dist/src/trails/add-surface.js.map +0 -1
  78. package/dist/src/trails/add-trail.d.ts +0 -10
  79. package/dist/src/trails/add-trail.d.ts.map +0 -1
  80. package/dist/src/trails/add-trail.js +0 -77
  81. package/dist/src/trails/add-trail.js.map +0 -1
  82. package/dist/src/trails/add-trailhead.d.ts +0 -13
  83. package/dist/src/trails/add-trailhead.d.ts.map +0 -1
  84. package/dist/src/trails/add-trailhead.js +0 -88
  85. package/dist/src/trails/add-trailhead.js.map +0 -1
  86. package/dist/src/trails/add-verify.d.ts +0 -10
  87. package/dist/src/trails/add-verify.d.ts.map +0 -1
  88. package/dist/src/trails/add-verify.js +0 -67
  89. package/dist/src/trails/add-verify.js.map +0 -1
  90. package/dist/src/trails/create-scaffold.d.ts +0 -15
  91. package/dist/src/trails/create-scaffold.d.ts.map +0 -1
  92. package/dist/src/trails/create-scaffold.js +0 -288
  93. package/dist/src/trails/create-scaffold.js.map +0 -1
  94. package/dist/src/trails/create.d.ts +0 -22
  95. package/dist/src/trails/create.d.ts.map +0 -1
  96. package/dist/src/trails/create.js +0 -121
  97. package/dist/src/trails/create.js.map +0 -1
  98. package/dist/src/trails/dev-clean.d.ts +0 -9
  99. package/dist/src/trails/dev-clean.d.ts.map +0 -1
  100. package/dist/src/trails/dev-clean.js +0 -65
  101. package/dist/src/trails/dev-clean.js.map +0 -1
  102. package/dist/src/trails/dev-reset.d.ts +0 -6
  103. package/dist/src/trails/dev-reset.d.ts.map +0 -1
  104. package/dist/src/trails/dev-reset.js +0 -38
  105. package/dist/src/trails/dev-reset.js.map +0 -1
  106. package/dist/src/trails/dev-stats.d.ts +0 -7
  107. package/dist/src/trails/dev-stats.d.ts.map +0 -1
  108. package/dist/src/trails/dev-stats.js +0 -61
  109. package/dist/src/trails/dev-stats.js.map +0 -1
  110. package/dist/src/trails/dev-support.d.ts +0 -64
  111. package/dist/src/trails/dev-support.d.ts.map +0 -1
  112. package/dist/src/trails/dev-support.js +0 -178
  113. package/dist/src/trails/dev-support.js.map +0 -1
  114. package/dist/src/trails/draft-promote.d.ts +0 -18
  115. package/dist/src/trails/draft-promote.d.ts.map +0 -1
  116. package/dist/src/trails/draft-promote.js +0 -386
  117. package/dist/src/trails/draft-promote.js.map +0 -1
  118. package/dist/src/trails/guide.d.ts +0 -21
  119. package/dist/src/trails/guide.d.ts.map +0 -1
  120. package/dist/src/trails/guide.js +0 -64
  121. package/dist/src/trails/guide.js.map +0 -1
  122. package/dist/src/trails/load-app.d.ts +0 -6
  123. package/dist/src/trails/load-app.d.ts.map +0 -1
  124. package/dist/src/trails/load-app.js +0 -67
  125. package/dist/src/trails/load-app.js.map +0 -1
  126. package/dist/src/trails/project.d.ts +0 -8
  127. package/dist/src/trails/project.d.ts.map +0 -1
  128. package/dist/src/trails/project.js +0 -54
  129. package/dist/src/trails/project.js.map +0 -1
  130. package/dist/src/trails/survey.d.ts +0 -18
  131. package/dist/src/trails/survey.d.ts.map +0 -1
  132. package/dist/src/trails/survey.js +0 -212
  133. package/dist/src/trails/survey.js.map +0 -1
  134. package/dist/src/trails/topo-constants.d.ts +0 -3
  135. package/dist/src/trails/topo-constants.d.ts.map +0 -1
  136. package/dist/src/trails/topo-constants.js +0 -3
  137. package/dist/src/trails/topo-constants.js.map +0 -1
  138. package/dist/src/trails/topo-export.d.ts +0 -18
  139. package/dist/src/trails/topo-export.d.ts.map +0 -1
  140. package/dist/src/trails/topo-export.js +0 -34
  141. package/dist/src/trails/topo-export.js.map +0 -1
  142. package/dist/src/trails/topo-history.d.ts +0 -24
  143. package/dist/src/trails/topo-history.d.ts.map +0 -1
  144. package/dist/src/trails/topo-history.js +0 -33
  145. package/dist/src/trails/topo-history.js.map +0 -1
  146. package/dist/src/trails/topo-pin.d.ts +0 -21
  147. package/dist/src/trails/topo-pin.d.ts.map +0 -1
  148. package/dist/src/trails/topo-pin.js +0 -35
  149. package/dist/src/trails/topo-pin.js.map +0 -1
  150. package/dist/src/trails/topo-read-support.d.ts +0 -54
  151. package/dist/src/trails/topo-read-support.d.ts.map +0 -1
  152. package/dist/src/trails/topo-read-support.js +0 -178
  153. package/dist/src/trails/topo-read-support.js.map +0 -1
  154. package/dist/src/trails/topo-reports.d.ts +0 -50
  155. package/dist/src/trails/topo-reports.d.ts.map +0 -1
  156. package/dist/src/trails/topo-reports.js +0 -122
  157. package/dist/src/trails/topo-reports.js.map +0 -1
  158. package/dist/src/trails/topo-show.d.ts +0 -23
  159. package/dist/src/trails/topo-show.d.ts.map +0 -1
  160. package/dist/src/trails/topo-show.js +0 -53
  161. package/dist/src/trails/topo-show.js.map +0 -1
  162. package/dist/src/trails/topo-store-support.d.ts +0 -13
  163. package/dist/src/trails/topo-store-support.d.ts.map +0 -1
  164. package/dist/src/trails/topo-store-support.js +0 -55
  165. package/dist/src/trails/topo-store-support.js.map +0 -1
  166. package/dist/src/trails/topo-support.d.ts +0 -87
  167. package/dist/src/trails/topo-support.d.ts.map +0 -1
  168. package/dist/src/trails/topo-support.js +0 -165
  169. package/dist/src/trails/topo-support.js.map +0 -1
  170. package/dist/src/trails/topo-unpin.d.ts +0 -15
  171. package/dist/src/trails/topo-unpin.d.ts.map +0 -1
  172. package/dist/src/trails/topo-unpin.js +0 -39
  173. package/dist/src/trails/topo-unpin.js.map +0 -1
  174. package/dist/src/trails/topo-verify.d.ts +0 -5
  175. package/dist/src/trails/topo-verify.d.ts.map +0 -1
  176. package/dist/src/trails/topo-verify.js +0 -28
  177. package/dist/src/trails/topo-verify.js.map +0 -1
  178. package/dist/src/trails/topo.d.ts +0 -5
  179. package/dist/src/trails/topo.d.ts.map +0 -1
  180. package/dist/src/trails/topo.js +0 -67
  181. package/dist/src/trails/topo.js.map +0 -1
  182. package/dist/src/trails/warden.d.ts +0 -19
  183. package/dist/src/trails/warden.d.ts.map +0 -1
  184. package/dist/src/trails/warden.js +0 -89
  185. package/dist/src/trails/warden.js.map +0 -1
  186. package/dist/tsconfig.tsbuildinfo +0 -1
  187. package/src/__tests__/create.test.ts +0 -351
  188. package/src/__tests__/draft-promote.test.ts +0 -144
  189. package/src/__tests__/guide.test.ts +0 -91
  190. package/src/__tests__/load-app.test.ts +0 -58
  191. package/src/__tests__/survey.test.ts +0 -301
  192. package/src/__tests__/topo-dev.test.ts +0 -424
  193. package/src/__tests__/warden.test.ts +0 -74
  194. package/src/trails/add-trailhead.ts +0 -121
  195. package/src/trails/topo-export.ts +0 -39
  196. package/src/trails/topo-show.ts +0 -58
  197. package/tsconfig.json +0 -9
@@ -1,24 +1,41 @@
1
- import { existsSync, renameSync, statSync } from 'node:fs';
2
- import { basename, dirname, join, relative } from 'node:path';
1
+ import { existsSync, statSync } from 'node:fs';
2
+ import {
3
+ basename,
4
+ dirname,
5
+ isAbsolute,
6
+ join,
7
+ relative,
8
+ resolve,
9
+ } from 'node:path';
3
10
 
4
11
  import {
5
12
  Result,
6
13
  ValidationError,
7
- analyzeDraftState,
14
+ deriveDraftReport,
8
15
  isDraftId,
9
16
  trail,
10
17
  } from '@ontrails/core';
18
+ import type { Topo } from '@ontrails/core';
11
19
  import {
12
20
  DRAFT_FILE_PREFIX,
13
- findStringLiterals,
14
21
  isDraftMarkedFile,
15
- parse,
16
22
  stripDraftFileMarkers,
17
23
  } from '@ontrails/warden';
24
+ import { findStringLiterals, parse } from '@ontrails/warden/ast';
18
25
  import { z } from 'zod';
19
26
 
20
- import { loadApp } from './load-app.js';
27
+ import {
28
+ applyProjectOperations,
29
+ planProjectOperations,
30
+ } from '../project-writes.js';
31
+ import type {
32
+ PlannedProjectOperation,
33
+ ProjectWriteOperation,
34
+ } from '../project-writes.js';
35
+ import type { FreshAppLease } from './load-app.js';
36
+ import { tryLoadFreshAppLease } from './load-app.js';
21
37
  import { findTopoPath } from './project.js';
38
+ import { resolveTrailRootDir } from './root-dir.js';
22
39
 
23
40
  interface PromotionEdit {
24
41
  readonly end: number;
@@ -164,10 +181,8 @@ const replaceLiteralValue = (
164
181
  };
165
182
  };
166
183
 
167
- const collectOutputId = (
168
- app: Awaited<ReturnType<typeof loadApp>>,
169
- id: string
170
- ) => app.get(id) ?? app.signals.get(id) ?? app.getProvision(id);
184
+ const collectOutputId = (app: Topo, id: string) =>
185
+ app.get(id) ?? app.signals.get(id) ?? app.getResource(id);
171
186
 
172
187
  const toRelativeOutputPath = (rootDir: string, filePath: string): string =>
173
188
  relative(rootDir, filePath).replaceAll('\\', '/');
@@ -178,6 +193,7 @@ const toProjectModulePath = (sourceImport: string): string =>
178
193
  : sourceImport;
179
194
 
180
195
  interface PromotionRewriteState {
196
+ readonly plannedOperations: PlannedProjectOperation[];
181
197
  readonly renames: FileRename[];
182
198
  readonly updatedSourceFiles: Set<string>;
183
199
  }
@@ -185,7 +201,6 @@ interface PromotionRewriteState {
185
201
  interface PromotionLoadState {
186
202
  readonly appModule: string | null;
187
203
  readonly loadError: string | null;
188
- readonly loadedApp: Awaited<ReturnType<typeof loadApp>> | undefined;
189
204
  }
190
205
 
191
206
  const validatePromotionInput = (input: {
@@ -242,7 +257,22 @@ const resolveValidatedPromotionRoot = (
242
257
  return validation;
243
258
  }
244
259
 
245
- const rootDir = input.rootDir ?? ctx.cwd ?? process.cwd();
260
+ let rootDir: string;
261
+ if (input.rootDir === undefined) {
262
+ const cwdResult = resolveTrailRootDir(undefined, ctx.cwd);
263
+ if (cwdResult.isErr()) {
264
+ return cwdResult;
265
+ }
266
+ rootDir = resolve(cwdResult.value);
267
+ } else if (isAbsolute(input.rootDir)) {
268
+ rootDir = resolve(input.rootDir);
269
+ } else {
270
+ const cwdResult = resolveTrailRootDir(undefined, ctx.cwd);
271
+ if (cwdResult.isErr()) {
272
+ return cwdResult;
273
+ }
274
+ rootDir = resolve(cwdResult.value, input.rootDir);
275
+ }
246
276
  const rootValidation = validatePromotionRoot(rootDir);
247
277
  if (rootValidation.isErr()) {
248
278
  return rootValidation;
@@ -251,27 +281,83 @@ const resolveValidatedPromotionRoot = (
251
281
  return Result.ok(rootDir);
252
282
  };
253
283
 
254
- const rewritePromotedSourceFiles = async (
284
+ type SourceFileMap = Map<string, string>;
285
+ type WriteProjectOperation = Extract<
286
+ ProjectWriteOperation,
287
+ { readonly kind: 'write' }
288
+ >;
289
+
290
+ const pushWriteOperation = (
291
+ operations: ProjectWriteOperation[],
292
+ operation: WriteProjectOperation
293
+ ): void => {
294
+ for (let index = operations.length - 1; index >= 0; index -= 1) {
295
+ const existing = operations[index];
296
+ if (
297
+ existing?.kind === 'rename' &&
298
+ (existing.from === operation.path || existing.to === operation.path)
299
+ ) {
300
+ break;
301
+ }
302
+ if (existing?.kind === 'write' && existing.path === operation.path) {
303
+ operations[index] = operation;
304
+ return;
305
+ }
306
+ }
307
+
308
+ operations.push(operation);
309
+ };
310
+
311
+ const readSourceFiles = async (
312
+ filePaths: readonly string[]
313
+ ): Promise<Result<SourceFileMap, Error>> => {
314
+ const sources = new Map<string, string>();
315
+ for (const filePath of filePaths) {
316
+ try {
317
+ sources.set(filePath, await Bun.file(filePath).text());
318
+ } catch (error) {
319
+ return Result.err(
320
+ new ValidationError(
321
+ `Cannot read source file "${filePath}"`,
322
+ error instanceof Error ? { cause: error } : undefined
323
+ )
324
+ );
325
+ }
326
+ }
327
+ return Result.ok(sources);
328
+ };
329
+
330
+ const planPromotedSourceFiles = (
255
331
  filePaths: readonly string[],
332
+ sources: SourceFileMap,
256
333
  fromId: string,
257
334
  toId: string,
258
- updatedSourceFiles: Set<string>
259
- ): Promise<void> => {
335
+ updatedSourceFiles: Set<string>,
336
+ operations: ProjectWriteOperation[]
337
+ ): Result<void, Error> => {
260
338
  for (const filePath of filePaths) {
261
- const sourceCode = await Bun.file(filePath).text();
339
+ const sourceCode = sources.get(filePath);
340
+ if (sourceCode === undefined) {
341
+ return Result.err(
342
+ new ValidationError(`Cannot read source file "${filePath}"`)
343
+ );
344
+ }
345
+
262
346
  const replaced = replaceIdLiterals(sourceCode, filePath, fromId, toId);
263
347
  if (!replaced.changed) {
264
348
  continue;
265
349
  }
266
350
 
267
- await Bun.write(filePath, replaced.nextSource);
351
+ sources.set(filePath, replaced.nextSource);
352
+ pushWriteOperation(operations, {
353
+ content: replaced.nextSource,
354
+ kind: 'write',
355
+ path: filePath,
356
+ });
268
357
  updatedSourceFiles.add(filePath);
269
358
  }
270
- };
271
359
 
272
- const hasDraftIdsInFile = async (filePath: string): Promise<boolean> => {
273
- const sourceCode = await Bun.file(filePath).text();
274
- return hasDraftIds(sourceCode, filePath);
360
+ return Result.ok();
275
361
  };
276
362
 
277
363
  const buildPromotableFileRename = (
@@ -295,9 +381,10 @@ const buildPromotableFileRename = (
295
381
  return Result.ok({ from: filePath, to: nextPath });
296
382
  };
297
383
 
298
- const collectPromotableFileRename = async (
299
- filePath: string
300
- ): Promise<Result<FileRename | null, Error>> => {
384
+ const collectPromotableFileRename = (
385
+ filePath: string,
386
+ sourceCode: string
387
+ ): Result<FileRename | null, Error> => {
301
388
  if (!isDraftMarkedFile(filePath)) {
302
389
  return Result.ok(null);
303
390
  }
@@ -307,7 +394,7 @@ const collectPromotableFileRename = async (
307
394
  return Result.ok(null);
308
395
  }
309
396
 
310
- if (await hasDraftIdsInFile(filePath)) {
397
+ if (hasDraftIds(sourceCode, filePath)) {
311
398
  return Result.ok(null);
312
399
  }
313
400
 
@@ -339,12 +426,19 @@ const validateRenameTargets = (
339
426
  return Result.ok();
340
427
  };
341
428
 
342
- const collectFileRenames = async (
343
- filePaths: readonly string[]
344
- ): Promise<Result<FileRename[], Error>> => {
429
+ const collectFileRenames = (
430
+ filePaths: readonly string[],
431
+ sources: SourceFileMap
432
+ ): Result<FileRename[], Error> => {
345
433
  const renames: FileRename[] = [];
346
434
  for (const filePath of filePaths) {
347
- const renameResult = await collectPromotableFileRename(filePath);
435
+ const sourceCode = sources.get(filePath);
436
+ if (sourceCode === undefined) {
437
+ return Result.err(
438
+ new ValidationError(`Cannot read source file "${filePath}"`)
439
+ );
440
+ }
441
+ const renameResult = collectPromotableFileRename(filePath, sourceCode);
348
442
  if (renameResult.isErr()) {
349
443
  return renameResult;
350
444
  }
@@ -355,26 +449,6 @@ const collectFileRenames = async (
355
449
  return Result.ok(renames);
356
450
  };
357
451
 
358
- const collectAndApplyFileRenames = async (
359
- filePaths: readonly string[]
360
- ): Promise<Result<FileRename[], Error>> => {
361
- const collected = await collectFileRenames(filePaths);
362
- if (collected.isErr()) {
363
- return collected;
364
- }
365
-
366
- const renames = collected.value;
367
- const valid = validateRenameTargets(renames);
368
- if (valid.isErr()) {
369
- return valid;
370
- }
371
-
372
- for (const r of renames) {
373
- renameSync(r.from, r.to);
374
- }
375
- return Result.ok(renames);
376
- };
377
-
378
452
  const applyRenameEffects = (
379
453
  updatedSourceFiles: Set<string>,
380
454
  renames: readonly FileRename[]
@@ -386,6 +460,30 @@ const applyRenameEffects = (
386
460
  }
387
461
  };
388
462
 
463
+ const applySourceRenameEffects = (
464
+ sources: SourceFileMap,
465
+ renames: readonly FileRename[]
466
+ ): void => {
467
+ for (const rename of renames) {
468
+ const sourceCode = sources.get(rename.from);
469
+ if (sourceCode === undefined) {
470
+ continue;
471
+ }
472
+ sources.delete(rename.from);
473
+ sources.set(rename.to, sourceCode);
474
+ }
475
+ };
476
+
477
+ const applyFilePathRenames = (
478
+ filePaths: readonly string[],
479
+ renames: readonly FileRename[]
480
+ ): string[] => {
481
+ const renamedBySource = new Map(
482
+ renames.map((rename) => [rename.from, rename.to])
483
+ );
484
+ return filePaths.map((filePath) => renamedBySource.get(filePath) ?? filePath);
485
+ };
486
+
389
487
  const applyRelativeImportRename = (
390
488
  sourceCode: string,
391
489
  filePath: string,
@@ -431,69 +529,128 @@ const rewriteRelativeImportsForFile = (
431
529
  return { changed, sourceCode: nextSourceCode };
432
530
  };
433
531
 
434
- const updateRelativeImportsForFile = async (
532
+ const planRelativeImportsForFile = (
435
533
  filePath: string,
436
- renames: readonly FileRename[]
437
- ): Promise<boolean> => {
438
- const sourceCode = await Bun.file(filePath).text();
534
+ renames: readonly FileRename[],
535
+ sources: SourceFileMap,
536
+ operations: ProjectWriteOperation[]
537
+ ): Result<boolean, Error> => {
538
+ const sourceCode = sources.get(filePath);
539
+ if (sourceCode === undefined) {
540
+ return Result.err(
541
+ new ValidationError(`Cannot read source file "${filePath}"`)
542
+ );
543
+ }
544
+
439
545
  const updated = rewriteRelativeImportsForFile(filePath, renames, sourceCode);
440
546
  if (updated.changed) {
441
- await Bun.write(filePath, updated.sourceCode);
442
- return true;
547
+ sources.set(filePath, updated.sourceCode);
548
+ pushWriteOperation(operations, {
549
+ content: updated.sourceCode,
550
+ kind: 'write',
551
+ path: filePath,
552
+ });
553
+ return Result.ok(true);
443
554
  }
444
555
 
445
- return false;
556
+ return Result.ok(false);
446
557
  };
447
558
 
448
- const updateRelativeImports = async (
559
+ const planRelativeImports = (
449
560
  filePaths: readonly string[],
450
- renames: readonly FileRename[]
451
- ): Promise<string[]> => {
452
- const updatedFiles = new Set<string>();
453
-
561
+ renames: readonly FileRename[],
562
+ sources: SourceFileMap,
563
+ updatedSourceFiles: Set<string>,
564
+ operations: ProjectWriteOperation[]
565
+ ): Result<void, Error> => {
454
566
  for (const filePath of filePaths) {
455
- const changed = await updateRelativeImportsForFile(filePath, renames);
456
- if (changed) {
457
- updatedFiles.add(filePath);
567
+ const changed = planRelativeImportsForFile(
568
+ filePath,
569
+ renames,
570
+ sources,
571
+ operations
572
+ );
573
+ if (changed.isErr()) {
574
+ return Result.err(changed.error);
575
+ }
576
+ if (changed.value) {
577
+ updatedSourceFiles.add(filePath);
458
578
  }
459
579
  }
460
580
 
461
- return [...updatedFiles].toSorted();
581
+ return Result.ok();
462
582
  };
463
583
 
464
584
  const rewritePromotionState = async (
465
585
  rootDir: string,
466
586
  input: {
587
+ readonly dryRun?: boolean | undefined;
467
588
  readonly fromId: string;
468
589
  readonly renameFiles: boolean;
469
590
  readonly toId: string;
470
591
  }
471
592
  ): Promise<Result<PromotionRewriteState, Error>> => {
472
593
  const initialFiles = collectTsFiles(rootDir);
594
+ const sourcesResult = await readSourceFiles(initialFiles);
595
+ if (sourcesResult.isErr()) {
596
+ return Result.err(sourcesResult.error);
597
+ }
598
+ const sources = sourcesResult.value;
599
+ const operations: ProjectWriteOperation[] = [];
473
600
  const updatedSourceFiles = new Set<string>();
474
601
 
475
- await rewritePromotedSourceFiles(
602
+ const rewritten = planPromotedSourceFiles(
476
603
  initialFiles,
604
+ sources,
477
605
  input.fromId,
478
606
  input.toId,
479
- updatedSourceFiles
607
+ updatedSourceFiles,
608
+ operations
480
609
  );
610
+ if (rewritten.isErr()) {
611
+ return Result.err(rewritten.error);
612
+ }
481
613
 
482
614
  const renamesResult = input.renameFiles
483
- ? await collectAndApplyFileRenames(initialFiles)
615
+ ? collectFileRenames(initialFiles, sources)
484
616
  : Result.ok([] as FileRename[]);
485
617
  if (renamesResult.isErr()) {
486
618
  return Result.err(renamesResult.error);
487
619
  }
488
620
 
621
+ const valid = validateRenameTargets(renamesResult.value);
622
+ if (valid.isErr()) {
623
+ return valid;
624
+ }
625
+
626
+ for (const rename of renamesResult.value) {
627
+ operations.push({ from: rename.from, kind: 'rename', to: rename.to });
628
+ }
629
+
489
630
  applyRenameEffects(updatedSourceFiles, renamesResult.value);
490
- for (const f of await updateRelativeImports(
491
- collectTsFiles(rootDir),
492
- renamesResult.value
493
- )) {
494
- updatedSourceFiles.add(f);
631
+ applySourceRenameEffects(sources, renamesResult.value);
632
+ const importUpdates = planRelativeImports(
633
+ applyFilePathRenames(initialFiles, renamesResult.value),
634
+ renamesResult.value,
635
+ sources,
636
+ updatedSourceFiles,
637
+ operations
638
+ );
639
+ if (importUpdates.isErr()) {
640
+ return Result.err(importUpdates.error);
495
641
  }
496
- return Result.ok({ renames: renamesResult.value, updatedSourceFiles });
642
+
643
+ const plannedOperations = input.dryRun
644
+ ? planProjectOperations(rootDir, operations)
645
+ : await applyProjectOperations(rootDir, operations);
646
+ if (plannedOperations.isErr()) {
647
+ return Result.err(plannedOperations.error);
648
+ }
649
+ return Result.ok({
650
+ plannedOperations: plannedOperations.value,
651
+ renames: renamesResult.value,
652
+ updatedSourceFiles,
653
+ });
497
654
  };
498
655
 
499
656
  const resolvePromotionAppModule = async (
@@ -511,23 +668,55 @@ const resolvePromotionAppModule = async (
511
668
  );
512
669
  };
513
670
 
514
- const loadVerifiedApp = async (
515
- appModule: string | null,
671
+ /**
672
+ * Run `consume` while holding a fresh-load lease on `appModule`.
673
+ *
674
+ * @remarks
675
+ * The lease deletes its on-disk mirror on release. Any Topo consumption that
676
+ * may trigger deferred filesystem imports (for example inside a trail's
677
+ * `blaze` or a lazy relative `import()`) must run before the lease is
678
+ * released, otherwise those resolutions race the mirror teardown. Collapsing
679
+ * consumption into the leased critical section keeps that contract
680
+ * structural rather than relying on the caller to discover it.
681
+ */
682
+ type LeaseAttempt =
683
+ | {
684
+ readonly ok: true;
685
+ readonly lease: FreshAppLease;
686
+ }
687
+ | { readonly ok: false; readonly loadError: string };
688
+
689
+ const tryAcquireLease = async (
690
+ appModule: string,
516
691
  rootDir: string
517
- ): Promise<PromotionLoadState> => {
692
+ ): Promise<LeaseAttempt> => {
693
+ const loaded = await tryLoadFreshAppLease(appModule, rootDir);
694
+ if (loaded.isErr()) {
695
+ return { loadError: loaded.error.message, ok: false };
696
+ }
697
+ return { lease: loaded.value, ok: true };
698
+ };
699
+
700
+ const withVerifiedApp = async <T>(
701
+ appModule: string | null,
702
+ rootDir: string,
703
+ consume: (app: Topo) => T | Promise<T>
704
+ ): Promise<{ readonly load: PromotionLoadState; readonly value: T | null }> => {
518
705
  if (appModule === null) {
519
- return { appModule, loadError: null, loadedApp: undefined };
706
+ return { load: { appModule, loadError: null }, value: null };
520
707
  }
521
708
 
522
- let loadError: string | null = null;
523
- const loadedApp = await loadApp(appModule, rootDir, { fresh: true }).catch(
524
- (error: unknown): undefined => {
525
- loadError = error instanceof Error ? error.message : String(error);
526
- return undefined;
527
- }
528
- );
709
+ const attempt = await tryAcquireLease(appModule, rootDir);
710
+ if (!attempt.ok) {
711
+ return { load: { appModule, loadError: attempt.loadError }, value: null };
712
+ }
529
713
 
530
- return { appModule, loadError, loadedApp };
714
+ try {
715
+ const value = await consume(attempt.lease.app);
716
+ return { load: { appModule, loadError: null }, value };
717
+ } finally {
718
+ attempt.lease.release();
719
+ }
531
720
  };
532
721
 
533
722
  const toRenamedFiles = (rootDir: string, renames: readonly FileRename[]) =>
@@ -541,19 +730,33 @@ const toUpdatedFiles = (rootDir: string, updatedSourceFiles: Set<string>) =>
541
730
  .toSorted()
542
731
  .map((filePath) => toRelativeOutputPath(rootDir, filePath));
543
732
 
733
+ const buildUnverifiedPromotionMessage = (
734
+ loadError: string | null,
735
+ dryRun: boolean
736
+ ): string => {
737
+ if (dryRun) {
738
+ return 'Promotion plan is valid. Re-run without dryRun to apply it.';
739
+ }
740
+
741
+ return loadError === null
742
+ ? 'Promotion rewrote source files, but no topo entrypoint could be loaded for verification.'
743
+ : `Promotion rewrote source files, but verification failed: ${loadError}`;
744
+ };
745
+
544
746
  const buildUnverifiedPromotionResult = (
545
747
  rootDir: string,
546
748
  loadError: string | null,
547
749
  renames: readonly FileRename[],
548
750
  updatedSourceFiles: Set<string>,
549
- appModule: string | null
751
+ appModule: string | null,
752
+ plannedOperations: readonly PlannedProjectOperation[],
753
+ dryRun: boolean
550
754
  ) =>
551
755
  Result.ok({
552
756
  appModule,
553
- message:
554
- loadError === null
555
- ? 'Promotion rewrote source files, but no topo entrypoint could be loaded for verification.'
556
- : `Promotion rewrote source files, but verification failed: ${loadError}`,
757
+ dryRun,
758
+ message: buildUnverifiedPromotionMessage(loadError, dryRun),
759
+ plannedOperations,
557
760
  promotedEstablished: false,
558
761
  remainingDraftIds: [],
559
762
  renamedFiles: toRenamedFiles(rootDir, renames),
@@ -562,11 +765,12 @@ const buildUnverifiedPromotionResult = (
562
765
 
563
766
  const buildVerifiedPromotionResult = (
564
767
  rootDir: string,
565
- analysis: ReturnType<typeof analyzeDraftState>,
768
+ analysis: ReturnType<typeof deriveDraftReport>,
566
769
  promotedEstablished: boolean,
567
770
  renames: readonly FileRename[],
568
771
  updatedSourceFiles: Set<string>,
569
772
  appModule: string | null,
773
+ plannedOperations: readonly PlannedProjectOperation[],
570
774
  toId: string
571
775
  ) => {
572
776
  const blockingFinding = analysis.findings.find(
@@ -575,11 +779,13 @@ const buildVerifiedPromotionResult = (
575
779
 
576
780
  return Result.ok({
577
781
  appModule,
782
+ dryRun: false,
578
783
  message:
579
784
  blockingFinding?.message ??
580
785
  (promotedEstablished
581
786
  ? `Promoted "${toId}" is now established.`
582
787
  : `Promoted "${toId}" could not be verified as established.`),
788
+ plannedOperations,
583
789
  promotedEstablished,
584
790
  remainingDraftIds: [...analysis.declaredDraftIds].toSorted(),
585
791
  renamedFiles: toRenamedFiles(rootDir, renames),
@@ -589,13 +795,14 @@ const buildVerifiedPromotionResult = (
589
795
 
590
796
  const buildVerifiedPromotionResultFromApp = (
591
797
  rootDir: string,
592
- loadedApp: Awaited<ReturnType<typeof loadApp>>,
798
+ loadedApp: Topo,
593
799
  renames: readonly FileRename[],
594
800
  updatedSourceFiles: Set<string>,
595
801
  appModule: string | null,
802
+ plannedOperations: readonly PlannedProjectOperation[],
596
803
  toId: string
597
804
  ) => {
598
- const analysis = analyzeDraftState(loadedApp);
805
+ const analysis = deriveDraftReport(loadedApp);
599
806
  const promotedNode = collectOutputId(loadedApp, toId);
600
807
  const promotedEstablished =
601
808
  promotedNode !== undefined && !analysis.contaminatedIds.has(toId);
@@ -607,6 +814,7 @@ const buildVerifiedPromotionResultFromApp = (
607
814
  renames,
608
815
  updatedSourceFiles,
609
816
  appModule,
817
+ plannedOperations,
610
818
  toId
611
819
  );
612
820
  };
@@ -614,6 +822,7 @@ const buildVerifiedPromotionResultFromApp = (
614
822
  const promoteDraftState = async (
615
823
  input: {
616
824
  readonly appModule?: string | undefined;
825
+ readonly dryRun?: boolean | undefined;
617
826
  readonly fromId: string;
618
827
  readonly renameFiles: boolean;
619
828
  readonly rootDir?: string | undefined;
@@ -633,27 +842,45 @@ const promoteDraftState = async (
633
842
 
634
843
  const { renames, updatedSourceFiles } = rewriteResult.value;
635
844
  const appModule = await resolvePromotionAppModule(input, rootDirResult.value);
636
- const { loadError, loadedApp } = await loadVerifiedApp(
637
- appModule,
638
- rootDirResult.value
639
- );
845
+ if (input.dryRun === true) {
846
+ return buildUnverifiedPromotionResult(
847
+ rootDirResult.value,
848
+ null,
849
+ renames,
850
+ updatedSourceFiles,
851
+ appModule,
852
+ rewriteResult.value.plannedOperations,
853
+ true
854
+ );
855
+ }
640
856
 
641
- return loadedApp
642
- ? buildVerifiedPromotionResultFromApp(
857
+ const { load, value } = await withVerifiedApp(
858
+ appModule,
859
+ rootDirResult.value,
860
+ (loadedApp) =>
861
+ buildVerifiedPromotionResultFromApp(
643
862
  rootDirResult.value,
644
863
  loadedApp,
645
864
  renames,
646
865
  updatedSourceFiles,
647
866
  appModule,
867
+ rewriteResult.value.plannedOperations,
648
868
  input.toId
649
869
  )
650
- : buildUnverifiedPromotionResult(
651
- rootDirResult.value,
652
- loadError,
653
- renames,
654
- updatedSourceFiles,
655
- appModule
656
- );
870
+ );
871
+
872
+ return (
873
+ value ??
874
+ buildUnverifiedPromotionResult(
875
+ rootDirResult.value,
876
+ load.loadError,
877
+ renames,
878
+ updatedSourceFiles,
879
+ appModule,
880
+ rewriteResult.value.plannedOperations,
881
+ false
882
+ )
883
+ );
657
884
  };
658
885
 
659
886
  export const draftPromoteTrail = trail('draft.promote', {
@@ -664,6 +891,7 @@ export const draftPromoteTrail = trail('draft.promote', {
664
891
  {
665
892
  error: 'ValidationError',
666
893
  input: {
894
+ // warden-ignore-next-line
667
895
  fromId: '_draft.entity.prepare',
668
896
  renameFiles: true,
669
897
  rootDir: './__does_not_exist__/draft-promote-example',
@@ -677,6 +905,10 @@ export const draftPromoteTrail = trail('draft.promote', {
677
905
  .string()
678
906
  .optional()
679
907
  .describe('Optional app module to verify after promotion'),
908
+ dryRun: z
909
+ .boolean()
910
+ .default(false)
911
+ .describe('Plan promotion rewrites without touching source files'),
680
912
  fromId: z.string().describe('Draft id to promote'),
681
913
  renameFiles: z
682
914
  .boolean()
@@ -690,7 +922,19 @@ export const draftPromoteTrail = trail('draft.promote', {
690
922
  intent: 'write',
691
923
  output: z.object({
692
924
  appModule: z.string().nullable(),
925
+ dryRun: z.boolean(),
693
926
  message: z.string(),
927
+ plannedOperations: z.array(
928
+ z.discriminatedUnion('kind', [
929
+ z.object({ kind: z.literal('mkdir'), path: z.string() }),
930
+ z.object({
931
+ from: z.string(),
932
+ kind: z.literal('rename'),
933
+ to: z.string(),
934
+ }),
935
+ z.object({ kind: z.literal('write'), path: z.string() }),
936
+ ])
937
+ ),
694
938
  promotedEstablished: z.boolean(),
695
939
  remainingDraftIds: z.array(z.string()),
696
940
  renamedFiles: z.array(