@ontrails/trails 1.0.0-beta.15 → 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 (201) hide show
  1. package/CHANGELOG.md +197 -2
  2. package/README.md +27 -0
  3. package/package.json +19 -8
  4. package/src/app.ts +15 -5
  5. package/src/cli.ts +303 -10
  6. package/src/completions.ts +240 -0
  7. package/src/load-app-mirror.ts +160 -0
  8. package/src/local-state-io.ts +153 -0
  9. package/src/project-writes.ts +320 -0
  10. package/src/run-collision.ts +125 -0
  11. package/src/run-completions-install.ts +179 -0
  12. package/src/run-example.ts +149 -0
  13. package/src/run-examples.ts +148 -0
  14. package/src/run-quiet.ts +75 -0
  15. package/src/run-trace.ts +273 -0
  16. package/src/run-warden.ts +39 -0
  17. package/src/run-watch.ts +432 -0
  18. package/src/scaffold-versions.generated.ts +12 -0
  19. package/src/trails/add-surface.ts +45 -23
  20. package/src/trails/add-trail.ts +27 -17
  21. package/src/trails/add-verify.ts +57 -17
  22. package/src/trails/completions-complete.ts +165 -0
  23. package/src/trails/completions.ts +47 -0
  24. package/src/trails/create-scaffold.ts +86 -33
  25. package/src/trails/create.ts +11 -3
  26. package/src/trails/dev-clean.ts +6 -1
  27. package/src/trails/dev-reset.ts +6 -1
  28. package/src/trails/dev-stats.ts +6 -1
  29. package/src/trails/dev-support.ts +29 -17
  30. package/src/trails/draft-promote.ts +289 -80
  31. package/src/trails/guide.ts +54 -34
  32. package/src/trails/load-app.ts +251 -56
  33. package/src/trails/root-dir.ts +21 -0
  34. package/src/trails/run-example.ts +482 -0
  35. package/src/trails/run-examples.ts +141 -0
  36. package/src/trails/run.ts +403 -0
  37. package/src/trails/survey.ts +506 -200
  38. package/src/trails/topo-activation.ts +385 -0
  39. package/src/trails/topo-compile.ts +55 -0
  40. package/src/trails/topo-history.ts +6 -1
  41. package/src/trails/topo-output-schemas.ts +175 -0
  42. package/src/trails/topo-pin.ts +19 -6
  43. package/src/trails/topo-read-support.ts +171 -228
  44. package/src/trails/topo-reports.ts +400 -25
  45. package/src/trails/topo-store-support.ts +43 -19
  46. package/src/trails/topo-support.ts +18 -28
  47. package/src/trails/topo-unpin.ts +6 -1
  48. package/src/trails/topo-verify.ts +18 -5
  49. package/src/trails/topo.ts +60 -23
  50. package/src/trails/warden-guide.ts +121 -0
  51. package/src/trails/warden.ts +137 -56
  52. package/src/versions.ts +3 -18
  53. package/.turbo/turbo-build.log +0 -1
  54. package/.turbo/turbo-lint.log +0 -3
  55. package/.turbo/turbo-typecheck.log +0 -1
  56. package/__tests__/examples.test.ts +0 -45
  57. package/dist/bin/trails.d.ts +0 -3
  58. package/dist/bin/trails.d.ts.map +0 -1
  59. package/dist/bin/trails.js +0 -4
  60. package/dist/bin/trails.js.map +0 -1
  61. package/dist/src/app.d.ts +0 -2
  62. package/dist/src/app.d.ts.map +0 -1
  63. package/dist/src/app.js +0 -22
  64. package/dist/src/app.js.map +0 -1
  65. package/dist/src/clack.d.ts +0 -9
  66. package/dist/src/clack.d.ts.map +0 -1
  67. package/dist/src/clack.js +0 -84
  68. package/dist/src/clack.js.map +0 -1
  69. package/dist/src/cli.d.ts +0 -2
  70. package/dist/src/cli.d.ts.map +0 -1
  71. package/dist/src/cli.js +0 -14
  72. package/dist/src/cli.js.map +0 -1
  73. package/dist/src/trails/add-surface.d.ts +0 -13
  74. package/dist/src/trails/add-surface.d.ts.map +0 -1
  75. package/dist/src/trails/add-surface.js +0 -110
  76. package/dist/src/trails/add-surface.js.map +0 -1
  77. package/dist/src/trails/add-trail.d.ts +0 -12
  78. package/dist/src/trails/add-trail.d.ts.map +0 -1
  79. package/dist/src/trails/add-trail.js +0 -104
  80. package/dist/src/trails/add-trail.js.map +0 -1
  81. package/dist/src/trails/add-trailhead.d.ts +0 -13
  82. package/dist/src/trails/add-trailhead.d.ts.map +0 -1
  83. package/dist/src/trails/add-trailhead.js +0 -88
  84. package/dist/src/trails/add-trailhead.js.map +0 -1
  85. package/dist/src/trails/add-verify.d.ts +0 -10
  86. package/dist/src/trails/add-verify.d.ts.map +0 -1
  87. package/dist/src/trails/add-verify.js +0 -68
  88. package/dist/src/trails/add-verify.js.map +0 -1
  89. package/dist/src/trails/create-scaffold.d.ts +0 -15
  90. package/dist/src/trails/create-scaffold.d.ts.map +0 -1
  91. package/dist/src/trails/create-scaffold.js +0 -295
  92. package/dist/src/trails/create-scaffold.js.map +0 -1
  93. package/dist/src/trails/create.d.ts +0 -18
  94. package/dist/src/trails/create.d.ts.map +0 -1
  95. package/dist/src/trails/create.js +0 -126
  96. package/dist/src/trails/create.js.map +0 -1
  97. package/dist/src/trails/dev-clean.d.ts +0 -9
  98. package/dist/src/trails/dev-clean.d.ts.map +0 -1
  99. package/dist/src/trails/dev-clean.js +0 -66
  100. package/dist/src/trails/dev-clean.js.map +0 -1
  101. package/dist/src/trails/dev-reset.d.ts +0 -6
  102. package/dist/src/trails/dev-reset.d.ts.map +0 -1
  103. package/dist/src/trails/dev-reset.js +0 -39
  104. package/dist/src/trails/dev-reset.js.map +0 -1
  105. package/dist/src/trails/dev-stats.d.ts +0 -7
  106. package/dist/src/trails/dev-stats.d.ts.map +0 -1
  107. package/dist/src/trails/dev-stats.js +0 -61
  108. package/dist/src/trails/dev-stats.js.map +0 -1
  109. package/dist/src/trails/dev-support.d.ts +0 -64
  110. package/dist/src/trails/dev-support.d.ts.map +0 -1
  111. package/dist/src/trails/dev-support.js +0 -181
  112. package/dist/src/trails/dev-support.js.map +0 -1
  113. package/dist/src/trails/draft-promote.d.ts +0 -18
  114. package/dist/src/trails/draft-promote.d.ts.map +0 -1
  115. package/dist/src/trails/draft-promote.js +0 -400
  116. package/dist/src/trails/draft-promote.js.map +0 -1
  117. package/dist/src/trails/guide.d.ts +0 -21
  118. package/dist/src/trails/guide.d.ts.map +0 -1
  119. package/dist/src/trails/guide.js +0 -61
  120. package/dist/src/trails/guide.js.map +0 -1
  121. package/dist/src/trails/load-app.d.ts +0 -12
  122. package/dist/src/trails/load-app.d.ts.map +0 -1
  123. package/dist/src/trails/load-app.js +0 -415
  124. package/dist/src/trails/load-app.js.map +0 -1
  125. package/dist/src/trails/project.d.ts +0 -8
  126. package/dist/src/trails/project.d.ts.map +0 -1
  127. package/dist/src/trails/project.js +0 -54
  128. package/dist/src/trails/project.js.map +0 -1
  129. package/dist/src/trails/survey.d.ts +0 -18
  130. package/dist/src/trails/survey.d.ts.map +0 -1
  131. package/dist/src/trails/survey.js +0 -234
  132. package/dist/src/trails/survey.js.map +0 -1
  133. package/dist/src/trails/topo-constants.d.ts +0 -3
  134. package/dist/src/trails/topo-constants.d.ts.map +0 -1
  135. package/dist/src/trails/topo-constants.js +0 -3
  136. package/dist/src/trails/topo-constants.js.map +0 -1
  137. package/dist/src/trails/topo-export.d.ts +0 -19
  138. package/dist/src/trails/topo-export.d.ts.map +0 -1
  139. package/dist/src/trails/topo-export.js +0 -31
  140. package/dist/src/trails/topo-export.js.map +0 -1
  141. package/dist/src/trails/topo-history.d.ts +0 -20
  142. package/dist/src/trails/topo-history.d.ts.map +0 -1
  143. package/dist/src/trails/topo-history.js +0 -32
  144. package/dist/src/trails/topo-history.js.map +0 -1
  145. package/dist/src/trails/topo-pin.d.ts +0 -17
  146. package/dist/src/trails/topo-pin.d.ts.map +0 -1
  147. package/dist/src/trails/topo-pin.js +0 -31
  148. package/dist/src/trails/topo-pin.js.map +0 -1
  149. package/dist/src/trails/topo-read-support.d.ts +0 -58
  150. package/dist/src/trails/topo-read-support.d.ts.map +0 -1
  151. package/dist/src/trails/topo-read-support.js +0 -167
  152. package/dist/src/trails/topo-read-support.js.map +0 -1
  153. package/dist/src/trails/topo-reports.d.ts +0 -54
  154. package/dist/src/trails/topo-reports.d.ts.map +0 -1
  155. package/dist/src/trails/topo-reports.js +0 -128
  156. package/dist/src/trails/topo-reports.js.map +0 -1
  157. package/dist/src/trails/topo-show.d.ts +0 -23
  158. package/dist/src/trails/topo-show.d.ts.map +0 -1
  159. package/dist/src/trails/topo-show.js +0 -49
  160. package/dist/src/trails/topo-show.js.map +0 -1
  161. package/dist/src/trails/topo-store-support.d.ts +0 -13
  162. package/dist/src/trails/topo-store-support.d.ts.map +0 -1
  163. package/dist/src/trails/topo-store-support.js +0 -55
  164. package/dist/src/trails/topo-store-support.js.map +0 -1
  165. package/dist/src/trails/topo-support.d.ts +0 -76
  166. package/dist/src/trails/topo-support.d.ts.map +0 -1
  167. package/dist/src/trails/topo-support.js +0 -132
  168. package/dist/src/trails/topo-support.js.map +0 -1
  169. package/dist/src/trails/topo-unpin.d.ts +0 -20
  170. package/dist/src/trails/topo-unpin.d.ts.map +0 -1
  171. package/dist/src/trails/topo-unpin.js +0 -44
  172. package/dist/src/trails/topo-unpin.js.map +0 -1
  173. package/dist/src/trails/topo-verify.d.ts +0 -5
  174. package/dist/src/trails/topo-verify.d.ts.map +0 -1
  175. package/dist/src/trails/topo-verify.js +0 -24
  176. package/dist/src/trails/topo-verify.js.map +0 -1
  177. package/dist/src/trails/topo.d.ts +0 -5
  178. package/dist/src/trails/topo.d.ts.map +0 -1
  179. package/dist/src/trails/topo.js +0 -63
  180. package/dist/src/trails/topo.js.map +0 -1
  181. package/dist/src/trails/warden.d.ts +0 -20
  182. package/dist/src/trails/warden.d.ts.map +0 -1
  183. package/dist/src/trails/warden.js +0 -98
  184. package/dist/src/trails/warden.js.map +0 -1
  185. package/dist/src/versions.d.ts +0 -12
  186. package/dist/src/versions.d.ts.map +0 -1
  187. package/dist/src/versions.js +0 -23
  188. package/dist/src/versions.js.map +0 -1
  189. package/dist/tsconfig.tsbuildinfo +0 -1
  190. package/src/__tests__/add-trail.test.ts +0 -97
  191. package/src/__tests__/create.test.ts +0 -415
  192. package/src/__tests__/draft-promote.test.ts +0 -144
  193. package/src/__tests__/guide.test.ts +0 -96
  194. package/src/__tests__/load-app.test.ts +0 -419
  195. package/src/__tests__/survey.test.ts +0 -377
  196. package/src/__tests__/topo-dev.test.ts +0 -426
  197. package/src/__tests__/warden.test.ts +0 -74
  198. package/src/trails/topo-export.ts +0 -35
  199. package/src/trails/topo-show.ts +0 -54
  200. package/tsconfig.json +0 -9
  201. package/tsconfig.tests.json +0 -10
@@ -1,36 +1,43 @@
1
- import { existsSync, rmSync, statSync } from 'node:fs';
1
+ import { existsSync, statSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
 
4
- import {
5
- countPinnedSnapshots,
6
- countPrunableSnapshots,
7
- countTopoSnapshots,
8
- pruneUnpinnedSnapshots,
9
- } from '@ontrails/core/internal/topo-snapshots';
10
4
  import {
11
5
  openReadTrailsDb,
12
6
  openWriteTrailsDb,
13
7
  deriveTrailsDbPath,
14
8
  deriveTrailsDir,
15
- } from '@ontrails/core/internal/trails-db';
9
+ } from '@ontrails/core';
10
+ import {
11
+ countPinnedSnapshots,
12
+ countPrunableSnapshots,
13
+ countTopoSnapshots,
14
+ pruneUnpinnedSnapshots,
15
+ } from '@ontrails/topographer/backend-support';
16
16
  import {
17
17
  DEFAULT_MAX_AGE,
18
18
  DEFAULT_MAX_RECORDS,
19
19
  applyTraceCleanup,
20
20
  countTraceRecords,
21
21
  previewTraceCleanup,
22
- } from '@ontrails/tracing/internal/dev-state';
22
+ } from '@ontrails/tracing';
23
+
24
+ import { removeRootRelativeFileIfPresent } from '../local-state-io.js';
25
+
26
+ import { requireTrailRootDir } from './root-dir.js';
23
27
 
24
28
  export const DEFAULT_TOPO_SNAPSHOT_RETENTION = 50;
25
29
 
26
- const deriveRootDir = (cwd?: string): string => cwd ?? process.cwd();
30
+ const deriveRootDir = (cwd?: string): string => requireTrailRootDir(cwd);
27
31
 
28
- const removeIfPresent = (filePath: string): boolean => {
29
- if (!existsSync(filePath)) {
30
- return false;
32
+ const removeResetFileIfPresent = (
33
+ rootDir: string,
34
+ relativePath: string
35
+ ): boolean => {
36
+ const removed = removeRootRelativeFileIfPresent(rootDir, relativePath);
37
+ if (removed.isErr()) {
38
+ throw removed.error;
31
39
  }
32
- rmSync(filePath, { force: true });
33
- return true;
40
+ return removed.value;
34
41
  };
35
42
 
36
43
  export interface DevStatsReport {
@@ -132,7 +139,7 @@ const buildDbStats = (
132
139
  ): DevStatsReport['db'] => ({
133
140
  exists,
134
141
  fileSizeBytes: exists ? statSync(dbPath).size : 0,
135
- path: '.trails/trails.db',
142
+ path: '.trails/state/trails.db',
136
143
  });
137
144
 
138
145
  const emptyDevStats = (
@@ -250,6 +257,11 @@ const buildCleanReport = (
250
257
  };
251
258
 
252
259
  const RESET_FILES = [
260
+ '.trails/state/trails.db',
261
+ '.trails/state/trails.db-shm',
262
+ '.trails/state/trails.db-wal',
263
+ // Legacy paths (pre-state migration) — cleaned for one cycle so upgrading
264
+ // workspaces do not leave stale DB sidecars at old locations.
253
265
  '.trails/trails.db',
254
266
  '.trails/trails.db-shm',
255
267
  '.trails/trails.db-wal',
@@ -317,7 +329,7 @@ export const resetDevState = (options?: {
317
329
  }
318
330
 
319
331
  const removedFiles = files.filter((relativePath) =>
320
- removeIfPresent(join(rootDir, relativePath))
332
+ removeResetFileIfPresent(rootDir, relativePath)
321
333
  );
322
334
 
323
335
  return {
@@ -1,5 +1,12 @@
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,
@@ -11,15 +18,24 @@ import {
11
18
  import type { Topo } from '@ontrails/core';
12
19
  import {
13
20
  DRAFT_FILE_PREFIX,
14
- findStringLiterals,
15
21
  isDraftMarkedFile,
16
- parse,
17
22
  stripDraftFileMarkers,
18
23
  } from '@ontrails/warden';
24
+ import { findStringLiterals, parse } from '@ontrails/warden/ast';
19
25
  import { z } from 'zod';
20
26
 
21
- import { loadFreshAppLease } 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';
22
37
  import { findTopoPath } from './project.js';
38
+ import { resolveTrailRootDir } from './root-dir.js';
23
39
 
24
40
  interface PromotionEdit {
25
41
  readonly end: number;
@@ -177,6 +193,7 @@ const toProjectModulePath = (sourceImport: string): string =>
177
193
  : sourceImport;
178
194
 
179
195
  interface PromotionRewriteState {
196
+ readonly plannedOperations: PlannedProjectOperation[];
180
197
  readonly renames: FileRename[];
181
198
  readonly updatedSourceFiles: Set<string>;
182
199
  }
@@ -240,7 +257,22 @@ const resolveValidatedPromotionRoot = (
240
257
  return validation;
241
258
  }
242
259
 
243
- 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
+ }
244
276
  const rootValidation = validatePromotionRoot(rootDir);
245
277
  if (rootValidation.isErr()) {
246
278
  return rootValidation;
@@ -249,27 +281,83 @@ const resolveValidatedPromotionRoot = (
249
281
  return Result.ok(rootDir);
250
282
  };
251
283
 
252
- 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 = (
253
331
  filePaths: readonly string[],
332
+ sources: SourceFileMap,
254
333
  fromId: string,
255
334
  toId: string,
256
- updatedSourceFiles: Set<string>
257
- ): Promise<void> => {
335
+ updatedSourceFiles: Set<string>,
336
+ operations: ProjectWriteOperation[]
337
+ ): Result<void, Error> => {
258
338
  for (const filePath of filePaths) {
259
- 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
+
260
346
  const replaced = replaceIdLiterals(sourceCode, filePath, fromId, toId);
261
347
  if (!replaced.changed) {
262
348
  continue;
263
349
  }
264
350
 
265
- 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
+ });
266
357
  updatedSourceFiles.add(filePath);
267
358
  }
268
- };
269
359
 
270
- const hasDraftIdsInFile = async (filePath: string): Promise<boolean> => {
271
- const sourceCode = await Bun.file(filePath).text();
272
- return hasDraftIds(sourceCode, filePath);
360
+ return Result.ok();
273
361
  };
274
362
 
275
363
  const buildPromotableFileRename = (
@@ -293,9 +381,10 @@ const buildPromotableFileRename = (
293
381
  return Result.ok({ from: filePath, to: nextPath });
294
382
  };
295
383
 
296
- const collectPromotableFileRename = async (
297
- filePath: string
298
- ): Promise<Result<FileRename | null, Error>> => {
384
+ const collectPromotableFileRename = (
385
+ filePath: string,
386
+ sourceCode: string
387
+ ): Result<FileRename | null, Error> => {
299
388
  if (!isDraftMarkedFile(filePath)) {
300
389
  return Result.ok(null);
301
390
  }
@@ -305,7 +394,7 @@ const collectPromotableFileRename = async (
305
394
  return Result.ok(null);
306
395
  }
307
396
 
308
- if (await hasDraftIdsInFile(filePath)) {
397
+ if (hasDraftIds(sourceCode, filePath)) {
309
398
  return Result.ok(null);
310
399
  }
311
400
 
@@ -337,12 +426,19 @@ const validateRenameTargets = (
337
426
  return Result.ok();
338
427
  };
339
428
 
340
- const collectFileRenames = async (
341
- filePaths: readonly string[]
342
- ): Promise<Result<FileRename[], Error>> => {
429
+ const collectFileRenames = (
430
+ filePaths: readonly string[],
431
+ sources: SourceFileMap
432
+ ): Result<FileRename[], Error> => {
343
433
  const renames: FileRename[] = [];
344
434
  for (const filePath of filePaths) {
345
- 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);
346
442
  if (renameResult.isErr()) {
347
443
  return renameResult;
348
444
  }
@@ -353,26 +449,6 @@ const collectFileRenames = async (
353
449
  return Result.ok(renames);
354
450
  };
355
451
 
356
- const collectAndApplyFileRenames = async (
357
- filePaths: readonly string[]
358
- ): Promise<Result<FileRename[], Error>> => {
359
- const collected = await collectFileRenames(filePaths);
360
- if (collected.isErr()) {
361
- return collected;
362
- }
363
-
364
- const renames = collected.value;
365
- const valid = validateRenameTargets(renames);
366
- if (valid.isErr()) {
367
- return valid;
368
- }
369
-
370
- for (const r of renames) {
371
- renameSync(r.from, r.to);
372
- }
373
- return Result.ok(renames);
374
- };
375
-
376
452
  const applyRenameEffects = (
377
453
  updatedSourceFiles: Set<string>,
378
454
  renames: readonly FileRename[]
@@ -384,6 +460,30 @@ const applyRenameEffects = (
384
460
  }
385
461
  };
386
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
+
387
487
  const applyRelativeImportRename = (
388
488
  sourceCode: string,
389
489
  filePath: string,
@@ -429,69 +529,128 @@ const rewriteRelativeImportsForFile = (
429
529
  return { changed, sourceCode: nextSourceCode };
430
530
  };
431
531
 
432
- const updateRelativeImportsForFile = async (
532
+ const planRelativeImportsForFile = (
433
533
  filePath: string,
434
- renames: readonly FileRename[]
435
- ): Promise<boolean> => {
436
- 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
+
437
545
  const updated = rewriteRelativeImportsForFile(filePath, renames, sourceCode);
438
546
  if (updated.changed) {
439
- await Bun.write(filePath, updated.sourceCode);
440
- 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);
441
554
  }
442
555
 
443
- return false;
556
+ return Result.ok(false);
444
557
  };
445
558
 
446
- const updateRelativeImports = async (
559
+ const planRelativeImports = (
447
560
  filePaths: readonly string[],
448
- renames: readonly FileRename[]
449
- ): Promise<string[]> => {
450
- const updatedFiles = new Set<string>();
451
-
561
+ renames: readonly FileRename[],
562
+ sources: SourceFileMap,
563
+ updatedSourceFiles: Set<string>,
564
+ operations: ProjectWriteOperation[]
565
+ ): Result<void, Error> => {
452
566
  for (const filePath of filePaths) {
453
- const changed = await updateRelativeImportsForFile(filePath, renames);
454
- if (changed) {
455
- 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);
456
578
  }
457
579
  }
458
580
 
459
- return [...updatedFiles].toSorted();
581
+ return Result.ok();
460
582
  };
461
583
 
462
584
  const rewritePromotionState = async (
463
585
  rootDir: string,
464
586
  input: {
587
+ readonly dryRun?: boolean | undefined;
465
588
  readonly fromId: string;
466
589
  readonly renameFiles: boolean;
467
590
  readonly toId: string;
468
591
  }
469
592
  ): Promise<Result<PromotionRewriteState, Error>> => {
470
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[] = [];
471
600
  const updatedSourceFiles = new Set<string>();
472
601
 
473
- await rewritePromotedSourceFiles(
602
+ const rewritten = planPromotedSourceFiles(
474
603
  initialFiles,
604
+ sources,
475
605
  input.fromId,
476
606
  input.toId,
477
- updatedSourceFiles
607
+ updatedSourceFiles,
608
+ operations
478
609
  );
610
+ if (rewritten.isErr()) {
611
+ return Result.err(rewritten.error);
612
+ }
479
613
 
480
614
  const renamesResult = input.renameFiles
481
- ? await collectAndApplyFileRenames(initialFiles)
615
+ ? collectFileRenames(initialFiles, sources)
482
616
  : Result.ok([] as FileRename[]);
483
617
  if (renamesResult.isErr()) {
484
618
  return Result.err(renamesResult.error);
485
619
  }
486
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
+
487
630
  applyRenameEffects(updatedSourceFiles, renamesResult.value);
488
- for (const f of await updateRelativeImports(
489
- collectTsFiles(rootDir),
490
- renamesResult.value
491
- )) {
492
- 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);
641
+ }
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);
493
648
  }
494
- return Result.ok({ renames: renamesResult.value, updatedSourceFiles });
649
+ return Result.ok({
650
+ plannedOperations: plannedOperations.value,
651
+ renames: renamesResult.value,
652
+ updatedSourceFiles,
653
+ });
495
654
  };
496
655
 
497
656
  const resolvePromotionAppModule = async (
@@ -523,7 +682,7 @@ const resolvePromotionAppModule = async (
523
682
  type LeaseAttempt =
524
683
  | {
525
684
  readonly ok: true;
526
- readonly lease: Awaited<ReturnType<typeof loadFreshAppLease>>;
685
+ readonly lease: FreshAppLease;
527
686
  }
528
687
  | { readonly ok: false; readonly loadError: string };
529
688
 
@@ -531,12 +690,11 @@ const tryAcquireLease = async (
531
690
  appModule: string,
532
691
  rootDir: string
533
692
  ): Promise<LeaseAttempt> => {
534
- try {
535
- return { lease: await loadFreshAppLease(appModule, rootDir), ok: true };
536
- } catch (error) {
537
- const loadError = error instanceof Error ? error.message : String(error);
538
- return { loadError, ok: false };
693
+ const loaded = await tryLoadFreshAppLease(appModule, rootDir);
694
+ if (loaded.isErr()) {
695
+ return { loadError: loaded.error.message, ok: false };
539
696
  }
697
+ return { lease: loaded.value, ok: true };
540
698
  };
541
699
 
542
700
  const withVerifiedApp = async <T>(
@@ -572,19 +730,33 @@ const toUpdatedFiles = (rootDir: string, updatedSourceFiles: Set<string>) =>
572
730
  .toSorted()
573
731
  .map((filePath) => toRelativeOutputPath(rootDir, filePath));
574
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
+
575
746
  const buildUnverifiedPromotionResult = (
576
747
  rootDir: string,
577
748
  loadError: string | null,
578
749
  renames: readonly FileRename[],
579
750
  updatedSourceFiles: Set<string>,
580
- appModule: string | null
751
+ appModule: string | null,
752
+ plannedOperations: readonly PlannedProjectOperation[],
753
+ dryRun: boolean
581
754
  ) =>
582
755
  Result.ok({
583
756
  appModule,
584
- message:
585
- loadError === null
586
- ? 'Promotion rewrote source files, but no topo entrypoint could be loaded for verification.'
587
- : `Promotion rewrote source files, but verification failed: ${loadError}`,
757
+ dryRun,
758
+ message: buildUnverifiedPromotionMessage(loadError, dryRun),
759
+ plannedOperations,
588
760
  promotedEstablished: false,
589
761
  remainingDraftIds: [],
590
762
  renamedFiles: toRenamedFiles(rootDir, renames),
@@ -598,6 +770,7 @@ const buildVerifiedPromotionResult = (
598
770
  renames: readonly FileRename[],
599
771
  updatedSourceFiles: Set<string>,
600
772
  appModule: string | null,
773
+ plannedOperations: readonly PlannedProjectOperation[],
601
774
  toId: string
602
775
  ) => {
603
776
  const blockingFinding = analysis.findings.find(
@@ -606,11 +779,13 @@ const buildVerifiedPromotionResult = (
606
779
 
607
780
  return Result.ok({
608
781
  appModule,
782
+ dryRun: false,
609
783
  message:
610
784
  blockingFinding?.message ??
611
785
  (promotedEstablished
612
786
  ? `Promoted "${toId}" is now established.`
613
787
  : `Promoted "${toId}" could not be verified as established.`),
788
+ plannedOperations,
614
789
  promotedEstablished,
615
790
  remainingDraftIds: [...analysis.declaredDraftIds].toSorted(),
616
791
  renamedFiles: toRenamedFiles(rootDir, renames),
@@ -624,6 +799,7 @@ const buildVerifiedPromotionResultFromApp = (
624
799
  renames: readonly FileRename[],
625
800
  updatedSourceFiles: Set<string>,
626
801
  appModule: string | null,
802
+ plannedOperations: readonly PlannedProjectOperation[],
627
803
  toId: string
628
804
  ) => {
629
805
  const analysis = deriveDraftReport(loadedApp);
@@ -638,6 +814,7 @@ const buildVerifiedPromotionResultFromApp = (
638
814
  renames,
639
815
  updatedSourceFiles,
640
816
  appModule,
817
+ plannedOperations,
641
818
  toId
642
819
  );
643
820
  };
@@ -645,6 +822,7 @@ const buildVerifiedPromotionResultFromApp = (
645
822
  const promoteDraftState = async (
646
823
  input: {
647
824
  readonly appModule?: string | undefined;
825
+ readonly dryRun?: boolean | undefined;
648
826
  readonly fromId: string;
649
827
  readonly renameFiles: boolean;
650
828
  readonly rootDir?: string | undefined;
@@ -664,6 +842,18 @@ const promoteDraftState = async (
664
842
 
665
843
  const { renames, updatedSourceFiles } = rewriteResult.value;
666
844
  const appModule = await resolvePromotionAppModule(input, rootDirResult.value);
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
+ }
856
+
667
857
  const { load, value } = await withVerifiedApp(
668
858
  appModule,
669
859
  rootDirResult.value,
@@ -674,6 +864,7 @@ const promoteDraftState = async (
674
864
  renames,
675
865
  updatedSourceFiles,
676
866
  appModule,
867
+ rewriteResult.value.plannedOperations,
677
868
  input.toId
678
869
  )
679
870
  );
@@ -685,7 +876,9 @@ const promoteDraftState = async (
685
876
  load.loadError,
686
877
  renames,
687
878
  updatedSourceFiles,
688
- appModule
879
+ appModule,
880
+ rewriteResult.value.plannedOperations,
881
+ false
689
882
  )
690
883
  );
691
884
  };
@@ -712,6 +905,10 @@ export const draftPromoteTrail = trail('draft.promote', {
712
905
  .string()
713
906
  .optional()
714
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'),
715
912
  fromId: z.string().describe('Draft id to promote'),
716
913
  renameFiles: z
717
914
  .boolean()
@@ -725,7 +922,19 @@ export const draftPromoteTrail = trail('draft.promote', {
725
922
  intent: 'write',
726
923
  output: z.object({
727
924
  appModule: z.string().nullable(),
925
+ dryRun: z.boolean(),
728
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
+ ),
729
938
  promotedEstablished: z.boolean(),
730
939
  remainingDraftIds: z.array(z.string()),
731
940
  renamedFiles: z.array(