@ontrails/trails 1.0.0-beta.13 → 1.0.0-beta.15

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 (165) hide show
  1. package/.turbo/turbo-lint.log +1 -1
  2. package/CHANGELOG.md +29 -0
  3. package/__tests__/examples.test.ts +39 -0
  4. package/dist/src/app.d.ts.map +1 -1
  5. package/dist/src/app.js +12 -1
  6. package/dist/src/app.js.map +1 -1
  7. package/dist/src/cli.js +4 -3
  8. package/dist/src/cli.js.map +1 -1
  9. package/dist/src/trails/add-surface.d.ts +3 -3
  10. package/dist/src/trails/add-surface.d.ts.map +1 -1
  11. package/dist/src/trails/add-surface.js +46 -24
  12. package/dist/src/trails/add-surface.js.map +1 -1
  13. package/dist/src/trails/add-trail.d.ts +3 -1
  14. package/dist/src/trails/add-trail.d.ts.map +1 -1
  15. package/dist/src/trails/add-trail.js +49 -22
  16. package/dist/src/trails/add-trail.js.map +1 -1
  17. package/dist/src/trails/add-trailhead.d.ts +13 -0
  18. package/dist/src/trails/add-trailhead.d.ts.map +1 -0
  19. package/dist/src/trails/add-trailhead.js +88 -0
  20. package/dist/src/trails/add-trailhead.js.map +1 -0
  21. package/dist/src/trails/add-verify.d.ts +1 -1
  22. package/dist/src/trails/add-verify.d.ts.map +1 -1
  23. package/dist/src/trails/add-verify.js +17 -16
  24. package/dist/src/trails/add-verify.js.map +1 -1
  25. package/dist/src/trails/create-scaffold.d.ts +1 -1
  26. package/dist/src/trails/create-scaffold.d.ts.map +1 -1
  27. package/dist/src/trails/create-scaffold.js +34 -27
  28. package/dist/src/trails/create-scaffold.js.map +1 -1
  29. package/dist/src/trails/create.d.ts +9 -13
  30. package/dist/src/trails/create.d.ts.map +1 -1
  31. package/dist/src/trails/create.js +40 -35
  32. package/dist/src/trails/create.js.map +1 -1
  33. package/dist/src/trails/dev-clean.d.ts +9 -0
  34. package/dist/src/trails/dev-clean.d.ts.map +1 -0
  35. package/dist/src/trails/dev-clean.js +66 -0
  36. package/dist/src/trails/dev-clean.js.map +1 -0
  37. package/dist/src/trails/dev-reset.d.ts +6 -0
  38. package/dist/src/trails/dev-reset.d.ts.map +1 -0
  39. package/dist/src/trails/dev-reset.js +39 -0
  40. package/dist/src/trails/dev-reset.js.map +1 -0
  41. package/dist/src/trails/dev-stats.d.ts +7 -0
  42. package/dist/src/trails/dev-stats.d.ts.map +1 -0
  43. package/dist/src/trails/dev-stats.js +61 -0
  44. package/dist/src/trails/dev-stats.js.map +1 -0
  45. package/dist/src/trails/dev-support.d.ts +64 -0
  46. package/dist/src/trails/dev-support.d.ts.map +1 -0
  47. package/dist/src/trails/dev-support.js +181 -0
  48. package/dist/src/trails/dev-support.js.map +1 -0
  49. package/dist/src/trails/draft-promote.d.ts +18 -0
  50. package/dist/src/trails/draft-promote.d.ts.map +1 -0
  51. package/dist/src/trails/draft-promote.js +400 -0
  52. package/dist/src/trails/draft-promote.js.map +1 -0
  53. package/dist/src/trails/guide.d.ts +14 -4
  54. package/dist/src/trails/guide.d.ts.map +1 -1
  55. package/dist/src/trails/guide.js +22 -41
  56. package/dist/src/trails/guide.js.map +1 -1
  57. package/dist/src/trails/load-app.d.ts +9 -1
  58. package/dist/src/trails/load-app.d.ts.map +1 -1
  59. package/dist/src/trails/load-app.js +404 -13
  60. package/dist/src/trails/load-app.js.map +1 -1
  61. package/dist/src/trails/project.d.ts.map +1 -1
  62. package/dist/src/trails/project.js +14 -3
  63. package/dist/src/trails/project.js.map +1 -1
  64. package/dist/src/trails/survey.d.ts +6 -60
  65. package/dist/src/trails/survey.d.ts.map +1 -1
  66. package/dist/src/trails/survey.js +83 -182
  67. package/dist/src/trails/survey.js.map +1 -1
  68. package/dist/src/trails/topo-constants.d.ts +3 -0
  69. package/dist/src/trails/topo-constants.d.ts.map +1 -0
  70. package/dist/src/trails/topo-constants.js +3 -0
  71. package/dist/src/trails/topo-constants.js.map +1 -0
  72. package/dist/src/trails/topo-export.d.ts +19 -0
  73. package/dist/src/trails/topo-export.d.ts.map +1 -0
  74. package/dist/src/trails/topo-export.js +31 -0
  75. package/dist/src/trails/topo-export.js.map +1 -0
  76. package/dist/src/trails/topo-history.d.ts +20 -0
  77. package/dist/src/trails/topo-history.d.ts.map +1 -0
  78. package/dist/src/trails/topo-history.js +32 -0
  79. package/dist/src/trails/topo-history.js.map +1 -0
  80. package/dist/src/trails/topo-pin.d.ts +17 -0
  81. package/dist/src/trails/topo-pin.d.ts.map +1 -0
  82. package/dist/src/trails/topo-pin.js +31 -0
  83. package/dist/src/trails/topo-pin.js.map +1 -0
  84. package/dist/src/trails/topo-read-support.d.ts +58 -0
  85. package/dist/src/trails/topo-read-support.d.ts.map +1 -0
  86. package/dist/src/trails/topo-read-support.js +167 -0
  87. package/dist/src/trails/topo-read-support.js.map +1 -0
  88. package/dist/src/trails/topo-reports.d.ts +54 -0
  89. package/dist/src/trails/topo-reports.d.ts.map +1 -0
  90. package/dist/src/trails/topo-reports.js +128 -0
  91. package/dist/src/trails/topo-reports.js.map +1 -0
  92. package/dist/src/trails/topo-show.d.ts +23 -0
  93. package/dist/src/trails/topo-show.d.ts.map +1 -0
  94. package/dist/src/trails/topo-show.js +49 -0
  95. package/dist/src/trails/topo-show.js.map +1 -0
  96. package/dist/src/trails/topo-store-support.d.ts +13 -0
  97. package/dist/src/trails/topo-store-support.d.ts.map +1 -0
  98. package/dist/src/trails/topo-store-support.js +55 -0
  99. package/dist/src/trails/topo-store-support.js.map +1 -0
  100. package/dist/src/trails/topo-support.d.ts +76 -0
  101. package/dist/src/trails/topo-support.d.ts.map +1 -0
  102. package/dist/src/trails/topo-support.js +132 -0
  103. package/dist/src/trails/topo-support.js.map +1 -0
  104. package/dist/src/trails/topo-unpin.d.ts +20 -0
  105. package/dist/src/trails/topo-unpin.d.ts.map +1 -0
  106. package/dist/src/trails/topo-unpin.js +44 -0
  107. package/dist/src/trails/topo-unpin.js.map +1 -0
  108. package/dist/src/trails/topo-verify.d.ts +5 -0
  109. package/dist/src/trails/topo-verify.d.ts.map +1 -0
  110. package/dist/src/trails/topo-verify.js +24 -0
  111. package/dist/src/trails/topo-verify.js.map +1 -0
  112. package/dist/src/trails/topo.d.ts +5 -0
  113. package/dist/src/trails/topo.d.ts.map +1 -0
  114. package/dist/src/trails/topo.js +63 -0
  115. package/dist/src/trails/topo.js.map +1 -0
  116. package/dist/src/trails/warden.d.ts +3 -2
  117. package/dist/src/trails/warden.d.ts.map +1 -1
  118. package/dist/src/trails/warden.js +37 -27
  119. package/dist/src/trails/warden.js.map +1 -1
  120. package/dist/src/versions.d.ts +12 -0
  121. package/dist/src/versions.d.ts.map +1 -0
  122. package/dist/src/versions.js +23 -0
  123. package/dist/src/versions.js.map +1 -0
  124. package/dist/tsconfig.tsbuildinfo +1 -1
  125. package/package.json +8 -7
  126. package/src/__tests__/add-trail.test.ts +97 -0
  127. package/src/__tests__/create.test.ts +91 -27
  128. package/src/__tests__/draft-promote.test.ts +144 -0
  129. package/src/__tests__/guide.test.ts +10 -5
  130. package/src/__tests__/load-app.test.ts +406 -2
  131. package/src/__tests__/survey.test.ts +221 -60
  132. package/src/__tests__/topo-dev.test.ts +426 -0
  133. package/src/app.ts +24 -2
  134. package/src/clack.ts +1 -1
  135. package/src/cli.ts +4 -3
  136. package/src/trails/add-surface.ts +150 -0
  137. package/src/trails/add-trail.ts +46 -10
  138. package/src/trails/add-verify.ts +11 -6
  139. package/src/trails/create-scaffold.ts +16 -3
  140. package/src/trails/create.ts +76 -71
  141. package/src/trails/dev-clean.ts +77 -0
  142. package/src/trails/dev-reset.ts +45 -0
  143. package/src/trails/dev-stats.ts +67 -0
  144. package/src/trails/dev-support.ts +328 -0
  145. package/src/trails/draft-promote.ts +739 -0
  146. package/src/trails/guide.ts +23 -41
  147. package/src/trails/load-app.ts +556 -14
  148. package/src/trails/project.ts +17 -3
  149. package/src/trails/survey.ts +110 -285
  150. package/src/trails/topo-constants.ts +2 -0
  151. package/src/trails/topo-export.ts +35 -0
  152. package/src/trails/topo-history.ts +38 -0
  153. package/src/trails/topo-pin.ts +38 -0
  154. package/src/trails/topo-read-support.ts +329 -0
  155. package/src/trails/topo-reports.ts +228 -0
  156. package/src/trails/topo-show.ts +54 -0
  157. package/src/trails/topo-store-support.ts +104 -0
  158. package/src/trails/topo-support.ts +230 -0
  159. package/src/trails/topo-unpin.ts +56 -0
  160. package/src/trails/topo-verify.ts +25 -0
  161. package/src/trails/topo.ts +69 -0
  162. package/src/trails/warden.ts +13 -3
  163. package/src/versions.ts +43 -0
  164. package/tsconfig.tests.json +10 -0
  165. package/src/trails/add-trailhead.ts +0 -121
@@ -0,0 +1,739 @@
1
+ import { existsSync, renameSync, statSync } from 'node:fs';
2
+ import { basename, dirname, join, relative } from 'node:path';
3
+
4
+ import {
5
+ Result,
6
+ ValidationError,
7
+ deriveDraftReport,
8
+ isDraftId,
9
+ trail,
10
+ } from '@ontrails/core';
11
+ import type { Topo } from '@ontrails/core';
12
+ import {
13
+ DRAFT_FILE_PREFIX,
14
+ findStringLiterals,
15
+ isDraftMarkedFile,
16
+ parse,
17
+ stripDraftFileMarkers,
18
+ } from '@ontrails/warden';
19
+ import { z } from 'zod';
20
+
21
+ import { loadFreshAppLease } from './load-app.js';
22
+ import { findTopoPath } from './project.js';
23
+
24
+ interface PromotionEdit {
25
+ readonly end: number;
26
+ readonly replacement: string;
27
+ readonly start: number;
28
+ }
29
+
30
+ interface FileRename {
31
+ readonly from: string;
32
+ readonly to: string;
33
+ }
34
+
35
+ const isManagedSourceFile = (match: string): boolean =>
36
+ !match.endsWith('.d.ts') &&
37
+ !match.startsWith('node_modules/') &&
38
+ !match.startsWith('dist/') &&
39
+ !match.startsWith('.git/');
40
+
41
+ const collectTsFiles = (rootDir: string): string[] => {
42
+ const files: string[] = [];
43
+ for (const match of new Bun.Glob('**/*.ts').scanSync({
44
+ cwd: rootDir,
45
+ dot: false,
46
+ onlyFiles: true,
47
+ })) {
48
+ if (isManagedSourceFile(match)) {
49
+ files.push(join(rootDir, match));
50
+ }
51
+ }
52
+ return files.toSorted();
53
+ };
54
+
55
+ const applyEdits = (
56
+ sourceCode: string,
57
+ edits: readonly PromotionEdit[]
58
+ ): string => {
59
+ let updated = sourceCode;
60
+ for (const edit of [...edits].toSorted((a, b) => b.start - a.start)) {
61
+ updated =
62
+ updated.slice(0, edit.start) + edit.replacement + updated.slice(edit.end);
63
+ }
64
+ return updated;
65
+ };
66
+
67
+ const literalQuote = (raw: string): '"' | "'" | '`' => {
68
+ const [first] = raw;
69
+ return first === '"' || first === '`' ? first : "'";
70
+ };
71
+
72
+ const replaceQuotedLiteral = (
73
+ sourceCode: string,
74
+ start: number,
75
+ end: number,
76
+ nextValue: string
77
+ ): string => {
78
+ const quote = literalQuote(sourceCode.slice(start, end));
79
+ return `${quote}${nextValue}${quote}`;
80
+ };
81
+
82
+ const replaceIdLiterals = (
83
+ sourceCode: string,
84
+ filePath: string,
85
+ fromId: string,
86
+ toId: string
87
+ ): { readonly changed: boolean; readonly nextSource: string } => {
88
+ const ast = parse(filePath, sourceCode);
89
+ if (!ast) {
90
+ return { changed: false, nextSource: sourceCode };
91
+ }
92
+
93
+ const edits = findStringLiterals(ast, (value) => value === fromId).map(
94
+ (match) => ({
95
+ end: match.end,
96
+ replacement: replaceQuotedLiteral(
97
+ sourceCode,
98
+ match.start,
99
+ match.end,
100
+ toId
101
+ ),
102
+ start: match.start,
103
+ })
104
+ );
105
+
106
+ if (edits.length === 0) {
107
+ return { changed: false, nextSource: sourceCode };
108
+ }
109
+
110
+ return {
111
+ changed: true,
112
+ nextSource: applyEdits(sourceCode, edits),
113
+ };
114
+ };
115
+
116
+ const hasDraftIds = (sourceCode: string, filePath: string): boolean => {
117
+ const ast = parse(filePath, sourceCode);
118
+ if (!ast) {
119
+ return sourceCode.includes(DRAFT_FILE_PREFIX);
120
+ }
121
+ return findStringLiterals(ast, (value) => isDraftId(value)).length > 0;
122
+ };
123
+
124
+ const toJsPath = (filePath: string): string => filePath.replace(/\.ts$/, '.js');
125
+
126
+ const toRelativeModulePath = (fromFile: string, toFile: string): string => {
127
+ const rel = relative(dirname(fromFile), toJsPath(toFile)).replaceAll(
128
+ '\\',
129
+ '/'
130
+ );
131
+ return rel.startsWith('.') ? rel : `./${rel}`;
132
+ };
133
+
134
+ const replaceLiteralValue = (
135
+ sourceCode: string,
136
+ filePath: string,
137
+ currentValue: string,
138
+ nextValue: string
139
+ ): { readonly changed: boolean; readonly nextSource: string } => {
140
+ const ast = parse(filePath, sourceCode);
141
+ if (!ast) {
142
+ return { changed: false, nextSource: sourceCode };
143
+ }
144
+
145
+ const edits = findStringLiterals(ast, (value) => value === currentValue).map(
146
+ (match) => ({
147
+ end: match.end,
148
+ replacement: replaceQuotedLiteral(
149
+ sourceCode,
150
+ match.start,
151
+ match.end,
152
+ nextValue
153
+ ),
154
+ start: match.start,
155
+ })
156
+ );
157
+
158
+ if (edits.length === 0) {
159
+ return { changed: false, nextSource: sourceCode };
160
+ }
161
+
162
+ return {
163
+ changed: true,
164
+ nextSource: applyEdits(sourceCode, edits),
165
+ };
166
+ };
167
+
168
+ const collectOutputId = (app: Topo, id: string) =>
169
+ app.get(id) ?? app.signals.get(id) ?? app.getResource(id);
170
+
171
+ const toRelativeOutputPath = (rootDir: string, filePath: string): string =>
172
+ relative(rootDir, filePath).replaceAll('\\', '/');
173
+
174
+ const toProjectModulePath = (sourceImport: string): string =>
175
+ sourceImport.startsWith('./')
176
+ ? `./src/${sourceImport.slice(2)}`
177
+ : sourceImport;
178
+
179
+ interface PromotionRewriteState {
180
+ readonly renames: FileRename[];
181
+ readonly updatedSourceFiles: Set<string>;
182
+ }
183
+
184
+ interface PromotionLoadState {
185
+ readonly appModule: string | null;
186
+ readonly loadError: string | null;
187
+ }
188
+
189
+ const validatePromotionInput = (input: {
190
+ readonly fromId: string;
191
+ readonly toId: string;
192
+ }): Result<void, ValidationError> => {
193
+ if (!isDraftId(input.fromId)) {
194
+ return Result.err(
195
+ new ValidationError(
196
+ `fromId must use the reserved draft prefix: "${input.fromId}"`
197
+ )
198
+ );
199
+ }
200
+
201
+ if (isDraftId(input.toId)) {
202
+ return Result.err(
203
+ new ValidationError(
204
+ `toId must be established, not draft: "${input.toId}"`
205
+ )
206
+ );
207
+ }
208
+
209
+ return Result.ok();
210
+ };
211
+
212
+ const validatePromotionRoot = (
213
+ rootDir: string
214
+ ): Result<void, ValidationError> => {
215
+ if (!existsSync(rootDir)) {
216
+ return Result.err(
217
+ new ValidationError(`rootDir does not exist: "${rootDir}"`)
218
+ );
219
+ }
220
+
221
+ if (!statSync(rootDir).isDirectory()) {
222
+ return Result.err(
223
+ new ValidationError(`rootDir must be a directory: "${rootDir}"`)
224
+ );
225
+ }
226
+
227
+ return Result.ok();
228
+ };
229
+
230
+ const resolveValidatedPromotionRoot = (
231
+ input: {
232
+ readonly fromId: string;
233
+ readonly rootDir?: string | undefined;
234
+ readonly toId: string;
235
+ },
236
+ ctx: { readonly cwd?: string | undefined }
237
+ ): Result<string, ValidationError> => {
238
+ const validation = validatePromotionInput(input);
239
+ if (validation.isErr()) {
240
+ return validation;
241
+ }
242
+
243
+ const rootDir = input.rootDir ?? ctx.cwd ?? process.cwd();
244
+ const rootValidation = validatePromotionRoot(rootDir);
245
+ if (rootValidation.isErr()) {
246
+ return rootValidation;
247
+ }
248
+
249
+ return Result.ok(rootDir);
250
+ };
251
+
252
+ const rewritePromotedSourceFiles = async (
253
+ filePaths: readonly string[],
254
+ fromId: string,
255
+ toId: string,
256
+ updatedSourceFiles: Set<string>
257
+ ): Promise<void> => {
258
+ for (const filePath of filePaths) {
259
+ const sourceCode = await Bun.file(filePath).text();
260
+ const replaced = replaceIdLiterals(sourceCode, filePath, fromId, toId);
261
+ if (!replaced.changed) {
262
+ continue;
263
+ }
264
+
265
+ await Bun.write(filePath, replaced.nextSource);
266
+ updatedSourceFiles.add(filePath);
267
+ }
268
+ };
269
+
270
+ const hasDraftIdsInFile = async (filePath: string): Promise<boolean> => {
271
+ const sourceCode = await Bun.file(filePath).text();
272
+ return hasDraftIds(sourceCode, filePath);
273
+ };
274
+
275
+ const buildPromotableFileRename = (
276
+ filePath: string,
277
+ fileName: string
278
+ ): Result<FileRename | null, Error> => {
279
+ const nextFileName = stripDraftFileMarkers(fileName);
280
+ if (nextFileName === fileName) {
281
+ return Result.ok(null);
282
+ }
283
+
284
+ const nextPath = join(dirname(filePath), nextFileName);
285
+ if (nextPath !== filePath && existsSync(nextPath)) {
286
+ return Result.err(
287
+ new ValidationError(
288
+ `Cannot promote draft file "${filePath}" because "${nextPath}" already exists.`
289
+ )
290
+ );
291
+ }
292
+
293
+ return Result.ok({ from: filePath, to: nextPath });
294
+ };
295
+
296
+ const collectPromotableFileRename = async (
297
+ filePath: string
298
+ ): Promise<Result<FileRename | null, Error>> => {
299
+ if (!isDraftMarkedFile(filePath)) {
300
+ return Result.ok(null);
301
+ }
302
+
303
+ const fileName = basename(filePath);
304
+ if (!fileName) {
305
+ return Result.ok(null);
306
+ }
307
+
308
+ if (await hasDraftIdsInFile(filePath)) {
309
+ return Result.ok(null);
310
+ }
311
+
312
+ return buildPromotableFileRename(filePath, fileName);
313
+ };
314
+
315
+ /** Validate that no two renames target the same path and no target already exists. */
316
+ const validateRenameTargets = (
317
+ renames: readonly FileRename[]
318
+ ): Result<void, Error> => {
319
+ const targets = new Set<string>();
320
+ for (const r of renames) {
321
+ if (targets.has(r.to)) {
322
+ return Result.err(
323
+ new ValidationError(
324
+ `Duplicate rename target "${r.to}" — multiple draft files would be renamed to the same path`
325
+ )
326
+ );
327
+ }
328
+ if (existsSync(r.to)) {
329
+ return Result.err(
330
+ new ValidationError(
331
+ `Rename target "${r.to}" already exists — cannot overwrite`
332
+ )
333
+ );
334
+ }
335
+ targets.add(r.to);
336
+ }
337
+ return Result.ok();
338
+ };
339
+
340
+ const collectFileRenames = async (
341
+ filePaths: readonly string[]
342
+ ): Promise<Result<FileRename[], Error>> => {
343
+ const renames: FileRename[] = [];
344
+ for (const filePath of filePaths) {
345
+ const renameResult = await collectPromotableFileRename(filePath);
346
+ if (renameResult.isErr()) {
347
+ return renameResult;
348
+ }
349
+ if (renameResult.value !== null) {
350
+ renames.push(renameResult.value);
351
+ }
352
+ }
353
+ return Result.ok(renames);
354
+ };
355
+
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
+ const applyRenameEffects = (
377
+ updatedSourceFiles: Set<string>,
378
+ renames: readonly FileRename[]
379
+ ): void => {
380
+ for (const rename of renames) {
381
+ if (updatedSourceFiles.delete(rename.from)) {
382
+ updatedSourceFiles.add(rename.to);
383
+ }
384
+ }
385
+ };
386
+
387
+ const applyRelativeImportRename = (
388
+ sourceCode: string,
389
+ filePath: string,
390
+ rename: FileRename
391
+ ): { readonly changed: boolean; readonly sourceCode: string } => {
392
+ const currentValue = toRelativeModulePath(filePath, rename.from);
393
+ const nextValue = toRelativeModulePath(filePath, rename.to);
394
+ if (currentValue === nextValue) {
395
+ return { changed: false, sourceCode };
396
+ }
397
+
398
+ const replaced = replaceLiteralValue(
399
+ sourceCode,
400
+ filePath,
401
+ currentValue,
402
+ nextValue
403
+ );
404
+ if (!replaced.changed) {
405
+ return { changed: false, sourceCode };
406
+ }
407
+
408
+ return { changed: true, sourceCode: replaced.nextSource };
409
+ };
410
+
411
+ const rewriteRelativeImportsForFile = (
412
+ filePath: string,
413
+ renames: readonly FileRename[],
414
+ sourceCode: string
415
+ ): { readonly changed: boolean; readonly sourceCode: string } => {
416
+ let nextSourceCode = sourceCode;
417
+ let changed = false;
418
+
419
+ for (const rename of renames) {
420
+ const updated = applyRelativeImportRename(nextSourceCode, filePath, rename);
421
+ if (!updated.changed) {
422
+ continue;
423
+ }
424
+
425
+ nextSourceCode = updated.sourceCode;
426
+ changed = true;
427
+ }
428
+
429
+ return { changed, sourceCode: nextSourceCode };
430
+ };
431
+
432
+ const updateRelativeImportsForFile = async (
433
+ filePath: string,
434
+ renames: readonly FileRename[]
435
+ ): Promise<boolean> => {
436
+ const sourceCode = await Bun.file(filePath).text();
437
+ const updated = rewriteRelativeImportsForFile(filePath, renames, sourceCode);
438
+ if (updated.changed) {
439
+ await Bun.write(filePath, updated.sourceCode);
440
+ return true;
441
+ }
442
+
443
+ return false;
444
+ };
445
+
446
+ const updateRelativeImports = async (
447
+ filePaths: readonly string[],
448
+ renames: readonly FileRename[]
449
+ ): Promise<string[]> => {
450
+ const updatedFiles = new Set<string>();
451
+
452
+ for (const filePath of filePaths) {
453
+ const changed = await updateRelativeImportsForFile(filePath, renames);
454
+ if (changed) {
455
+ updatedFiles.add(filePath);
456
+ }
457
+ }
458
+
459
+ return [...updatedFiles].toSorted();
460
+ };
461
+
462
+ const rewritePromotionState = async (
463
+ rootDir: string,
464
+ input: {
465
+ readonly fromId: string;
466
+ readonly renameFiles: boolean;
467
+ readonly toId: string;
468
+ }
469
+ ): Promise<Result<PromotionRewriteState, Error>> => {
470
+ const initialFiles = collectTsFiles(rootDir);
471
+ const updatedSourceFiles = new Set<string>();
472
+
473
+ await rewritePromotedSourceFiles(
474
+ initialFiles,
475
+ input.fromId,
476
+ input.toId,
477
+ updatedSourceFiles
478
+ );
479
+
480
+ const renamesResult = input.renameFiles
481
+ ? await collectAndApplyFileRenames(initialFiles)
482
+ : Result.ok([] as FileRename[]);
483
+ if (renamesResult.isErr()) {
484
+ return Result.err(renamesResult.error);
485
+ }
486
+
487
+ applyRenameEffects(updatedSourceFiles, renamesResult.value);
488
+ for (const f of await updateRelativeImports(
489
+ collectTsFiles(rootDir),
490
+ renamesResult.value
491
+ )) {
492
+ updatedSourceFiles.add(f);
493
+ }
494
+ return Result.ok({ renames: renamesResult.value, updatedSourceFiles });
495
+ };
496
+
497
+ const resolvePromotionAppModule = async (
498
+ input: {
499
+ readonly appModule?: string | undefined;
500
+ },
501
+ rootDir: string
502
+ ): Promise<string | null> => {
503
+ const discoveredAppModule = await findTopoPath(rootDir);
504
+ return (
505
+ input.appModule ??
506
+ (discoveredAppModule === null
507
+ ? null
508
+ : toProjectModulePath(discoveredAppModule))
509
+ );
510
+ };
511
+
512
+ /**
513
+ * Run `consume` while holding a fresh-load lease on `appModule`.
514
+ *
515
+ * @remarks
516
+ * The lease deletes its on-disk mirror on release. Any Topo consumption that
517
+ * may trigger deferred filesystem imports (for example inside a trail's
518
+ * `blaze` or a lazy relative `import()`) must run before the lease is
519
+ * released, otherwise those resolutions race the mirror teardown. Collapsing
520
+ * consumption into the leased critical section keeps that contract
521
+ * structural rather than relying on the caller to discover it.
522
+ */
523
+ type LeaseAttempt =
524
+ | {
525
+ readonly ok: true;
526
+ readonly lease: Awaited<ReturnType<typeof loadFreshAppLease>>;
527
+ }
528
+ | { readonly ok: false; readonly loadError: string };
529
+
530
+ const tryAcquireLease = async (
531
+ appModule: string,
532
+ rootDir: string
533
+ ): 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 };
539
+ }
540
+ };
541
+
542
+ const withVerifiedApp = async <T>(
543
+ appModule: string | null,
544
+ rootDir: string,
545
+ consume: (app: Topo) => T | Promise<T>
546
+ ): Promise<{ readonly load: PromotionLoadState; readonly value: T | null }> => {
547
+ if (appModule === null) {
548
+ return { load: { appModule, loadError: null }, value: null };
549
+ }
550
+
551
+ const attempt = await tryAcquireLease(appModule, rootDir);
552
+ if (!attempt.ok) {
553
+ return { load: { appModule, loadError: attempt.loadError }, value: null };
554
+ }
555
+
556
+ try {
557
+ const value = await consume(attempt.lease.app);
558
+ return { load: { appModule, loadError: null }, value };
559
+ } finally {
560
+ attempt.lease.release();
561
+ }
562
+ };
563
+
564
+ const toRenamedFiles = (rootDir: string, renames: readonly FileRename[]) =>
565
+ renames.map((rename) => ({
566
+ from: toRelativeOutputPath(rootDir, rename.from),
567
+ to: toRelativeOutputPath(rootDir, rename.to),
568
+ }));
569
+
570
+ const toUpdatedFiles = (rootDir: string, updatedSourceFiles: Set<string>) =>
571
+ [...updatedSourceFiles]
572
+ .toSorted()
573
+ .map((filePath) => toRelativeOutputPath(rootDir, filePath));
574
+
575
+ const buildUnverifiedPromotionResult = (
576
+ rootDir: string,
577
+ loadError: string | null,
578
+ renames: readonly FileRename[],
579
+ updatedSourceFiles: Set<string>,
580
+ appModule: string | null
581
+ ) =>
582
+ Result.ok({
583
+ 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}`,
588
+ promotedEstablished: false,
589
+ remainingDraftIds: [],
590
+ renamedFiles: toRenamedFiles(rootDir, renames),
591
+ updatedFiles: toUpdatedFiles(rootDir, updatedSourceFiles),
592
+ });
593
+
594
+ const buildVerifiedPromotionResult = (
595
+ rootDir: string,
596
+ analysis: ReturnType<typeof deriveDraftReport>,
597
+ promotedEstablished: boolean,
598
+ renames: readonly FileRename[],
599
+ updatedSourceFiles: Set<string>,
600
+ appModule: string | null,
601
+ toId: string
602
+ ) => {
603
+ const blockingFinding = analysis.findings.find(
604
+ (finding) => finding.id === toId
605
+ );
606
+
607
+ return Result.ok({
608
+ appModule,
609
+ message:
610
+ blockingFinding?.message ??
611
+ (promotedEstablished
612
+ ? `Promoted "${toId}" is now established.`
613
+ : `Promoted "${toId}" could not be verified as established.`),
614
+ promotedEstablished,
615
+ remainingDraftIds: [...analysis.declaredDraftIds].toSorted(),
616
+ renamedFiles: toRenamedFiles(rootDir, renames),
617
+ updatedFiles: toUpdatedFiles(rootDir, updatedSourceFiles),
618
+ });
619
+ };
620
+
621
+ const buildVerifiedPromotionResultFromApp = (
622
+ rootDir: string,
623
+ loadedApp: Topo,
624
+ renames: readonly FileRename[],
625
+ updatedSourceFiles: Set<string>,
626
+ appModule: string | null,
627
+ toId: string
628
+ ) => {
629
+ const analysis = deriveDraftReport(loadedApp);
630
+ const promotedNode = collectOutputId(loadedApp, toId);
631
+ const promotedEstablished =
632
+ promotedNode !== undefined && !analysis.contaminatedIds.has(toId);
633
+
634
+ return buildVerifiedPromotionResult(
635
+ rootDir,
636
+ analysis,
637
+ promotedEstablished,
638
+ renames,
639
+ updatedSourceFiles,
640
+ appModule,
641
+ toId
642
+ );
643
+ };
644
+
645
+ const promoteDraftState = async (
646
+ input: {
647
+ readonly appModule?: string | undefined;
648
+ readonly fromId: string;
649
+ readonly renameFiles: boolean;
650
+ readonly rootDir?: string | undefined;
651
+ readonly toId: string;
652
+ },
653
+ ctx: { readonly cwd?: string | undefined }
654
+ ) => {
655
+ const rootDirResult = resolveValidatedPromotionRoot(input, ctx);
656
+ if (rootDirResult.isErr()) {
657
+ return rootDirResult;
658
+ }
659
+
660
+ const rewriteResult = await rewritePromotionState(rootDirResult.value, input);
661
+ if (rewriteResult.isErr()) {
662
+ return Result.err(rewriteResult.error);
663
+ }
664
+
665
+ const { renames, updatedSourceFiles } = rewriteResult.value;
666
+ const appModule = await resolvePromotionAppModule(input, rootDirResult.value);
667
+ const { load, value } = await withVerifiedApp(
668
+ appModule,
669
+ rootDirResult.value,
670
+ (loadedApp) =>
671
+ buildVerifiedPromotionResultFromApp(
672
+ rootDirResult.value,
673
+ loadedApp,
674
+ renames,
675
+ updatedSourceFiles,
676
+ appModule,
677
+ input.toId
678
+ )
679
+ );
680
+
681
+ return (
682
+ value ??
683
+ buildUnverifiedPromotionResult(
684
+ rootDirResult.value,
685
+ load.loadError,
686
+ renames,
687
+ updatedSourceFiles,
688
+ appModule
689
+ )
690
+ );
691
+ };
692
+
693
+ export const draftPromoteTrail = trail('draft.promote', {
694
+ blaze: promoteDraftState,
695
+ description:
696
+ 'Promote a draft id to an established id, rewrite inbound references, and verify the result against a fresh topo load.',
697
+ examples: [
698
+ {
699
+ error: 'ValidationError',
700
+ input: {
701
+ // warden-ignore-next-line
702
+ fromId: '_draft.entity.prepare',
703
+ renameFiles: true,
704
+ rootDir: './__does_not_exist__/draft-promote-example',
705
+ toId: 'entity.prepare',
706
+ },
707
+ name: 'Rejects a missing project root before any rewrite begins',
708
+ },
709
+ ],
710
+ input: z.object({
711
+ appModule: z
712
+ .string()
713
+ .optional()
714
+ .describe('Optional app module to verify after promotion'),
715
+ fromId: z.string().describe('Draft id to promote'),
716
+ renameFiles: z
717
+ .boolean()
718
+ .default(true)
719
+ .describe('Rename draft-marked files that no longer contain draft ids'),
720
+ rootDir: z.string().optional().describe('Project root directory'),
721
+ toId: z
722
+ .string()
723
+ .describe('Established id to write in place of the draft id'),
724
+ }),
725
+ intent: 'write',
726
+ output: z.object({
727
+ appModule: z.string().nullable(),
728
+ message: z.string(),
729
+ promotedEstablished: z.boolean(),
730
+ remainingDraftIds: z.array(z.string()),
731
+ renamedFiles: z.array(
732
+ z.object({
733
+ from: z.string(),
734
+ to: z.string(),
735
+ })
736
+ ),
737
+ updatedFiles: z.array(z.string()),
738
+ }),
739
+ });