@ontrails/trails 1.0.0-beta.2 → 1.0.0-beta.22

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 (150) hide show
  1. package/CHANGELOG.md +647 -0
  2. package/README.md +26 -0
  3. package/package.json +28 -7
  4. package/src/app.ts +86 -2
  5. package/src/clack.ts +22 -0
  6. package/src/cli.ts +330 -11
  7. package/src/completions.ts +240 -0
  8. package/src/lifecycle-source-io.ts +33 -0
  9. package/src/load-app-mirror.ts +202 -0
  10. package/src/local-state-io.ts +153 -0
  11. package/src/mcp-app.ts +30 -0
  12. package/src/mcp-options.ts +77 -0
  13. package/src/mcp.ts +8 -0
  14. package/src/project-writes.ts +377 -0
  15. package/src/release/bindings.ts +39 -0
  16. package/src/release/check.ts +818 -0
  17. package/src/release/config.ts +63 -0
  18. package/src/release/contract-facts.ts +425 -0
  19. package/src/release/index.ts +85 -0
  20. package/src/release/native-bun-publish.ts +651 -0
  21. package/src/release/native-bun-registry.ts +350 -0
  22. package/src/release/packed-artifacts-smoke.ts +236 -0
  23. package/src/release/smoke.ts +46 -0
  24. package/src/release/wayfinder-dogfood-smoke.ts +226 -0
  25. package/src/retired-topo-command.ts +36 -0
  26. package/src/run-adapter-check.ts +76 -0
  27. package/src/run-collision.ts +126 -0
  28. package/src/run-completions-install.ts +179 -0
  29. package/src/run-example.ts +149 -0
  30. package/src/run-examples.ts +148 -0
  31. package/src/run-quiet.ts +75 -0
  32. package/src/run-release-check.ts +74 -0
  33. package/src/run-trace.ts +273 -0
  34. package/src/run-warden.ts +39 -0
  35. package/src/run-watch.ts +432 -0
  36. package/src/scaffold-version-sync.ts +183 -0
  37. package/src/scaffold-versions.generated.ts +12 -0
  38. package/src/trails/adapter-check.ts +244 -0
  39. package/src/trails/add-surface.ts +94 -40
  40. package/src/trails/add-trail.ts +79 -41
  41. package/src/trails/add-verify.ts +95 -25
  42. package/src/trails/compile.ts +67 -0
  43. package/src/trails/completions-complete.ts +165 -0
  44. package/src/trails/completions.ts +47 -0
  45. package/src/trails/create-adapter.ts +1084 -0
  46. package/src/trails/create-scaffold.ts +399 -104
  47. package/src/trails/create-versions.ts +62 -0
  48. package/src/trails/create.ts +185 -71
  49. package/src/trails/deprecate.ts +59 -0
  50. package/src/trails/dev-clean.ts +82 -0
  51. package/src/trails/dev-reset.ts +50 -0
  52. package/src/trails/dev-stats.ts +72 -0
  53. package/src/trails/dev-support.ts +340 -0
  54. package/src/trails/doctor.ts +56 -0
  55. package/src/trails/draft-promote.ts +949 -0
  56. package/src/trails/guide.ts +74 -68
  57. package/src/trails/load-app.ts +1143 -15
  58. package/src/trails/project.ts +17 -3
  59. package/src/trails/release-check.ts +104 -0
  60. package/src/trails/release-smoke.ts +48 -0
  61. package/src/trails/revise.ts +53 -0
  62. package/src/trails/root-dir.ts +21 -0
  63. package/src/trails/run-example.ts +491 -0
  64. package/src/trails/run-examples.ts +145 -0
  65. package/src/trails/run.ts +410 -0
  66. package/src/trails/scaffold-json.ts +58 -0
  67. package/src/trails/survey.ts +881 -226
  68. package/src/trails/topo-activation.ts +385 -0
  69. package/src/trails/topo-constants.ts +2 -0
  70. package/src/trails/topo-history.ts +47 -0
  71. package/src/trails/topo-output-schemas.ts +248 -0
  72. package/src/trails/topo-pin.ts +52 -0
  73. package/src/trails/topo-read-support.ts +313 -0
  74. package/src/trails/topo-reports.ts +807 -0
  75. package/src/trails/topo-store-support.ts +174 -0
  76. package/src/trails/topo-support.ts +220 -0
  77. package/src/trails/topo-unpin.ts +61 -0
  78. package/src/trails/topo.ts +106 -0
  79. package/src/trails/validate.ts +38 -0
  80. package/src/trails/version-lifecycle-support.ts +945 -0
  81. package/src/trails/warden-guide.ts +129 -0
  82. package/src/trails/warden.ts +165 -58
  83. package/src/versions.ts +31 -0
  84. package/.turbo/turbo-build.log +0 -1
  85. package/.turbo/turbo-lint.log +0 -3
  86. package/.turbo/turbo-typecheck.log +0 -1
  87. package/__tests__/examples.test.ts +0 -6
  88. package/dist/bin/trails.d.ts +0 -3
  89. package/dist/bin/trails.d.ts.map +0 -1
  90. package/dist/bin/trails.js +0 -4
  91. package/dist/bin/trails.js.map +0 -1
  92. package/dist/src/app.d.ts +0 -2
  93. package/dist/src/app.d.ts.map +0 -1
  94. package/dist/src/app.js +0 -11
  95. package/dist/src/app.js.map +0 -1
  96. package/dist/src/clack.d.ts +0 -9
  97. package/dist/src/clack.d.ts.map +0 -1
  98. package/dist/src/clack.js +0 -62
  99. package/dist/src/clack.js.map +0 -1
  100. package/dist/src/cli.d.ts +0 -2
  101. package/dist/src/cli.d.ts.map +0 -1
  102. package/dist/src/cli.js +0 -13
  103. package/dist/src/cli.js.map +0 -1
  104. package/dist/src/trails/add-surface.d.ts +0 -13
  105. package/dist/src/trails/add-surface.d.ts.map +0 -1
  106. package/dist/src/trails/add-surface.js +0 -88
  107. package/dist/src/trails/add-surface.js.map +0 -1
  108. package/dist/src/trails/add-trail.d.ts +0 -11
  109. package/dist/src/trails/add-trail.d.ts.map +0 -1
  110. package/dist/src/trails/add-trail.js +0 -85
  111. package/dist/src/trails/add-trail.js.map +0 -1
  112. package/dist/src/trails/add-verify.d.ts +0 -10
  113. package/dist/src/trails/add-verify.d.ts.map +0 -1
  114. package/dist/src/trails/add-verify.js +0 -67
  115. package/dist/src/trails/add-verify.js.map +0 -1
  116. package/dist/src/trails/create-scaffold.d.ts +0 -15
  117. package/dist/src/trails/create-scaffold.d.ts.map +0 -1
  118. package/dist/src/trails/create-scaffold.js +0 -288
  119. package/dist/src/trails/create-scaffold.js.map +0 -1
  120. package/dist/src/trails/create.d.ts +0 -22
  121. package/dist/src/trails/create.d.ts.map +0 -1
  122. package/dist/src/trails/create.js +0 -121
  123. package/dist/src/trails/create.js.map +0 -1
  124. package/dist/src/trails/guide.d.ts +0 -11
  125. package/dist/src/trails/guide.d.ts.map +0 -1
  126. package/dist/src/trails/guide.js +0 -80
  127. package/dist/src/trails/guide.js.map +0 -1
  128. package/dist/src/trails/load-app.d.ts +0 -4
  129. package/dist/src/trails/load-app.d.ts.map +0 -1
  130. package/dist/src/trails/load-app.js +0 -24
  131. package/dist/src/trails/load-app.js.map +0 -1
  132. package/dist/src/trails/project.d.ts +0 -8
  133. package/dist/src/trails/project.d.ts.map +0 -1
  134. package/dist/src/trails/project.js +0 -43
  135. package/dist/src/trails/project.js.map +0 -1
  136. package/dist/src/trails/survey.d.ts +0 -33
  137. package/dist/src/trails/survey.d.ts.map +0 -1
  138. package/dist/src/trails/survey.js +0 -225
  139. package/dist/src/trails/survey.js.map +0 -1
  140. package/dist/src/trails/warden.d.ts +0 -19
  141. package/dist/src/trails/warden.d.ts.map +0 -1
  142. package/dist/src/trails/warden.js +0 -88
  143. package/dist/src/trails/warden.js.map +0 -1
  144. package/dist/tsconfig.tsbuildinfo +0 -1
  145. package/src/__tests__/create.test.ts +0 -349
  146. package/src/__tests__/guide.test.ts +0 -91
  147. package/src/__tests__/load-app.test.ts +0 -15
  148. package/src/__tests__/survey.test.ts +0 -161
  149. package/src/__tests__/warden.test.ts +0 -74
  150. package/tsconfig.json +0 -9
@@ -0,0 +1,949 @@
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';
10
+
11
+ import {
12
+ Result,
13
+ ValidationError,
14
+ deriveDraftReport,
15
+ isDraftId,
16
+ trail,
17
+ } from '@ontrails/core';
18
+ import type { Topo } from '@ontrails/core';
19
+ import {
20
+ DRAFT_FILE_PREFIX,
21
+ isDraftMarkedFile,
22
+ stripDraftFileMarkers,
23
+ } from '@ontrails/warden';
24
+ import { findStringLiterals, parse } from '@ontrails/warden/ast';
25
+ import { z } from 'zod';
26
+
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';
37
+ import { findTopoPath } from './project.js';
38
+ import { resolveTrailRootDir } from './root-dir.js';
39
+
40
+ interface PromotionEdit {
41
+ readonly end: number;
42
+ readonly replacement: string;
43
+ readonly start: number;
44
+ }
45
+
46
+ interface FileRename {
47
+ readonly from: string;
48
+ readonly to: string;
49
+ }
50
+
51
+ const isManagedSourceFile = (match: string): boolean =>
52
+ !match.endsWith('.d.ts') &&
53
+ !match.startsWith('node_modules/') &&
54
+ !match.startsWith('dist/') &&
55
+ !match.startsWith('.git/');
56
+
57
+ const collectTsFiles = (rootDir: string): string[] => {
58
+ const files: string[] = [];
59
+ for (const match of new Bun.Glob('**/*.ts').scanSync({
60
+ cwd: rootDir,
61
+ dot: false,
62
+ onlyFiles: true,
63
+ })) {
64
+ if (isManagedSourceFile(match)) {
65
+ files.push(join(rootDir, match));
66
+ }
67
+ }
68
+ return files.toSorted();
69
+ };
70
+
71
+ const applyEdits = (
72
+ sourceCode: string,
73
+ edits: readonly PromotionEdit[]
74
+ ): string => {
75
+ let updated = sourceCode;
76
+ for (const edit of [...edits].toSorted((a, b) => b.start - a.start)) {
77
+ updated =
78
+ updated.slice(0, edit.start) + edit.replacement + updated.slice(edit.end);
79
+ }
80
+ return updated;
81
+ };
82
+
83
+ const literalQuote = (raw: string): '"' | "'" | '`' => {
84
+ const [first] = raw;
85
+ return first === '"' || first === '`' ? first : "'";
86
+ };
87
+
88
+ const replaceQuotedLiteral = (
89
+ sourceCode: string,
90
+ start: number,
91
+ end: number,
92
+ nextValue: string
93
+ ): string => {
94
+ const quote = literalQuote(sourceCode.slice(start, end));
95
+ return `${quote}${nextValue}${quote}`;
96
+ };
97
+
98
+ const replaceIdLiterals = (
99
+ sourceCode: string,
100
+ filePath: string,
101
+ fromId: string,
102
+ toId: string
103
+ ): { readonly changed: boolean; readonly nextSource: string } => {
104
+ const ast = parse(filePath, sourceCode);
105
+ if (!ast) {
106
+ return { changed: false, nextSource: sourceCode };
107
+ }
108
+
109
+ const edits = findStringLiterals(ast, (value) => value === fromId).map(
110
+ (match) => ({
111
+ end: match.end,
112
+ replacement: replaceQuotedLiteral(
113
+ sourceCode,
114
+ match.start,
115
+ match.end,
116
+ toId
117
+ ),
118
+ start: match.start,
119
+ })
120
+ );
121
+
122
+ if (edits.length === 0) {
123
+ return { changed: false, nextSource: sourceCode };
124
+ }
125
+
126
+ return {
127
+ changed: true,
128
+ nextSource: applyEdits(sourceCode, edits),
129
+ };
130
+ };
131
+
132
+ const hasDraftIds = (sourceCode: string, filePath: string): boolean => {
133
+ const ast = parse(filePath, sourceCode);
134
+ if (!ast) {
135
+ return sourceCode.includes(DRAFT_FILE_PREFIX);
136
+ }
137
+ return findStringLiterals(ast, (value) => isDraftId(value)).length > 0;
138
+ };
139
+
140
+ const toJsPath = (filePath: string): string => filePath.replace(/\.ts$/, '.js');
141
+
142
+ const toRelativeModulePath = (fromFile: string, toFile: string): string => {
143
+ const rel = relative(dirname(fromFile), toJsPath(toFile)).replaceAll(
144
+ '\\',
145
+ '/'
146
+ );
147
+ return rel.startsWith('.') ? rel : `./${rel}`;
148
+ };
149
+
150
+ const replaceLiteralValue = (
151
+ sourceCode: string,
152
+ filePath: string,
153
+ currentValue: string,
154
+ nextValue: string
155
+ ): { readonly changed: boolean; readonly nextSource: string } => {
156
+ const ast = parse(filePath, sourceCode);
157
+ if (!ast) {
158
+ return { changed: false, nextSource: sourceCode };
159
+ }
160
+
161
+ const edits = findStringLiterals(ast, (value) => value === currentValue).map(
162
+ (match) => ({
163
+ end: match.end,
164
+ replacement: replaceQuotedLiteral(
165
+ sourceCode,
166
+ match.start,
167
+ match.end,
168
+ nextValue
169
+ ),
170
+ start: match.start,
171
+ })
172
+ );
173
+
174
+ if (edits.length === 0) {
175
+ return { changed: false, nextSource: sourceCode };
176
+ }
177
+
178
+ return {
179
+ changed: true,
180
+ nextSource: applyEdits(sourceCode, edits),
181
+ };
182
+ };
183
+
184
+ const collectOutputId = (app: Topo, id: string) =>
185
+ app.get(id) ?? app.signals.get(id) ?? app.getResource(id);
186
+
187
+ const toRelativeOutputPath = (rootDir: string, filePath: string): string =>
188
+ relative(rootDir, filePath).replaceAll('\\', '/');
189
+
190
+ const toProjectModulePath = (sourceImport: string): string =>
191
+ sourceImport.startsWith('./')
192
+ ? `./src/${sourceImport.slice(2)}`
193
+ : sourceImport;
194
+
195
+ interface PromotionRewriteState {
196
+ readonly plannedOperations: PlannedProjectOperation[];
197
+ readonly renames: FileRename[];
198
+ readonly updatedSourceFiles: Set<string>;
199
+ }
200
+
201
+ interface PromotionLoadState {
202
+ readonly appModule: string | null;
203
+ readonly loadError: string | null;
204
+ }
205
+
206
+ const validatePromotionInput = (input: {
207
+ readonly fromId: string;
208
+ readonly toId: string;
209
+ }): Result<void, ValidationError> => {
210
+ if (!isDraftId(input.fromId)) {
211
+ return Result.err(
212
+ new ValidationError(
213
+ `fromId must use the reserved draft prefix: "${input.fromId}"`
214
+ )
215
+ );
216
+ }
217
+
218
+ if (isDraftId(input.toId)) {
219
+ return Result.err(
220
+ new ValidationError(
221
+ `toId must be established, not draft: "${input.toId}"`
222
+ )
223
+ );
224
+ }
225
+
226
+ return Result.ok();
227
+ };
228
+
229
+ const validatePromotionRoot = (
230
+ rootDir: string
231
+ ): Result<void, ValidationError> => {
232
+ if (!existsSync(rootDir)) {
233
+ return Result.err(
234
+ new ValidationError(`rootDir does not exist: "${rootDir}"`)
235
+ );
236
+ }
237
+
238
+ if (!statSync(rootDir).isDirectory()) {
239
+ return Result.err(
240
+ new ValidationError(`rootDir must be a directory: "${rootDir}"`)
241
+ );
242
+ }
243
+
244
+ return Result.ok();
245
+ };
246
+
247
+ const resolveValidatedPromotionRoot = (
248
+ input: {
249
+ readonly fromId: string;
250
+ readonly rootDir?: string | undefined;
251
+ readonly toId: string;
252
+ },
253
+ ctx: { readonly cwd?: string | undefined }
254
+ ): Result<string, ValidationError> => {
255
+ const validation = validatePromotionInput(input);
256
+ if (validation.isErr()) {
257
+ return validation;
258
+ }
259
+
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
+ }
276
+ const rootValidation = validatePromotionRoot(rootDir);
277
+ if (rootValidation.isErr()) {
278
+ return rootValidation;
279
+ }
280
+
281
+ return Result.ok(rootDir);
282
+ };
283
+
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 = (
331
+ filePaths: readonly string[],
332
+ sources: SourceFileMap,
333
+ fromId: string,
334
+ toId: string,
335
+ updatedSourceFiles: Set<string>,
336
+ operations: ProjectWriteOperation[]
337
+ ): Result<void, Error> => {
338
+ for (const filePath of filePaths) {
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
+
346
+ const replaced = replaceIdLiterals(sourceCode, filePath, fromId, toId);
347
+ if (!replaced.changed) {
348
+ continue;
349
+ }
350
+
351
+ sources.set(filePath, replaced.nextSource);
352
+ pushWriteOperation(operations, {
353
+ content: replaced.nextSource,
354
+ kind: 'write',
355
+ path: filePath,
356
+ });
357
+ updatedSourceFiles.add(filePath);
358
+ }
359
+
360
+ return Result.ok();
361
+ };
362
+
363
+ const buildPromotableFileRename = (
364
+ filePath: string,
365
+ fileName: string
366
+ ): Result<FileRename | null, Error> => {
367
+ const nextFileName = stripDraftFileMarkers(fileName);
368
+ if (nextFileName === fileName) {
369
+ return Result.ok(null);
370
+ }
371
+
372
+ const nextPath = join(dirname(filePath), nextFileName);
373
+ if (nextPath !== filePath && existsSync(nextPath)) {
374
+ return Result.err(
375
+ new ValidationError(
376
+ `Cannot promote draft file "${filePath}" because "${nextPath}" already exists.`
377
+ )
378
+ );
379
+ }
380
+
381
+ return Result.ok({ from: filePath, to: nextPath });
382
+ };
383
+
384
+ const collectPromotableFileRename = (
385
+ filePath: string,
386
+ sourceCode: string
387
+ ): Result<FileRename | null, Error> => {
388
+ if (!isDraftMarkedFile(filePath)) {
389
+ return Result.ok(null);
390
+ }
391
+
392
+ const fileName = basename(filePath);
393
+ if (!fileName) {
394
+ return Result.ok(null);
395
+ }
396
+
397
+ if (hasDraftIds(sourceCode, filePath)) {
398
+ return Result.ok(null);
399
+ }
400
+
401
+ return buildPromotableFileRename(filePath, fileName);
402
+ };
403
+
404
+ /** Validate that no two renames target the same path and no target already exists. */
405
+ const validateRenameTargets = (
406
+ renames: readonly FileRename[]
407
+ ): Result<void, Error> => {
408
+ const targets = new Set<string>();
409
+ for (const r of renames) {
410
+ if (targets.has(r.to)) {
411
+ return Result.err(
412
+ new ValidationError(
413
+ `Duplicate rename target "${r.to}" — multiple draft files would be renamed to the same path`
414
+ )
415
+ );
416
+ }
417
+ if (existsSync(r.to)) {
418
+ return Result.err(
419
+ new ValidationError(
420
+ `Rename target "${r.to}" already exists — cannot overwrite`
421
+ )
422
+ );
423
+ }
424
+ targets.add(r.to);
425
+ }
426
+ return Result.ok();
427
+ };
428
+
429
+ const collectFileRenames = (
430
+ filePaths: readonly string[],
431
+ sources: SourceFileMap
432
+ ): Result<FileRename[], Error> => {
433
+ const renames: FileRename[] = [];
434
+ for (const filePath of filePaths) {
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);
442
+ if (renameResult.isErr()) {
443
+ return renameResult;
444
+ }
445
+ if (renameResult.value !== null) {
446
+ renames.push(renameResult.value);
447
+ }
448
+ }
449
+ return Result.ok(renames);
450
+ };
451
+
452
+ const applyRenameEffects = (
453
+ updatedSourceFiles: Set<string>,
454
+ renames: readonly FileRename[]
455
+ ): void => {
456
+ for (const rename of renames) {
457
+ if (updatedSourceFiles.delete(rename.from)) {
458
+ updatedSourceFiles.add(rename.to);
459
+ }
460
+ }
461
+ };
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
+
487
+ const applyRelativeImportRename = (
488
+ sourceCode: string,
489
+ filePath: string,
490
+ rename: FileRename
491
+ ): { readonly changed: boolean; readonly sourceCode: string } => {
492
+ const currentValue = toRelativeModulePath(filePath, rename.from);
493
+ const nextValue = toRelativeModulePath(filePath, rename.to);
494
+ if (currentValue === nextValue) {
495
+ return { changed: false, sourceCode };
496
+ }
497
+
498
+ const replaced = replaceLiteralValue(
499
+ sourceCode,
500
+ filePath,
501
+ currentValue,
502
+ nextValue
503
+ );
504
+ if (!replaced.changed) {
505
+ return { changed: false, sourceCode };
506
+ }
507
+
508
+ return { changed: true, sourceCode: replaced.nextSource };
509
+ };
510
+
511
+ const rewriteRelativeImportsForFile = (
512
+ filePath: string,
513
+ renames: readonly FileRename[],
514
+ sourceCode: string
515
+ ): { readonly changed: boolean; readonly sourceCode: string } => {
516
+ let nextSourceCode = sourceCode;
517
+ let changed = false;
518
+
519
+ for (const rename of renames) {
520
+ const updated = applyRelativeImportRename(nextSourceCode, filePath, rename);
521
+ if (!updated.changed) {
522
+ continue;
523
+ }
524
+
525
+ nextSourceCode = updated.sourceCode;
526
+ changed = true;
527
+ }
528
+
529
+ return { changed, sourceCode: nextSourceCode };
530
+ };
531
+
532
+ const planRelativeImportsForFile = (
533
+ filePath: string,
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
+
545
+ const updated = rewriteRelativeImportsForFile(filePath, renames, sourceCode);
546
+ if (updated.changed) {
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);
554
+ }
555
+
556
+ return Result.ok(false);
557
+ };
558
+
559
+ const planRelativeImports = (
560
+ filePaths: readonly string[],
561
+ renames: readonly FileRename[],
562
+ sources: SourceFileMap,
563
+ updatedSourceFiles: Set<string>,
564
+ operations: ProjectWriteOperation[]
565
+ ): Result<void, Error> => {
566
+ for (const filePath of filePaths) {
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);
578
+ }
579
+ }
580
+
581
+ return Result.ok();
582
+ };
583
+
584
+ const rewritePromotionState = async (
585
+ rootDir: string,
586
+ input: {
587
+ readonly dryRun?: boolean | undefined;
588
+ readonly fromId: string;
589
+ readonly renameFiles: boolean;
590
+ readonly toId: string;
591
+ }
592
+ ): Promise<Result<PromotionRewriteState, Error>> => {
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[] = [];
600
+ const updatedSourceFiles = new Set<string>();
601
+
602
+ const rewritten = planPromotedSourceFiles(
603
+ initialFiles,
604
+ sources,
605
+ input.fromId,
606
+ input.toId,
607
+ updatedSourceFiles,
608
+ operations
609
+ );
610
+ if (rewritten.isErr()) {
611
+ return Result.err(rewritten.error);
612
+ }
613
+
614
+ const renamesResult = input.renameFiles
615
+ ? collectFileRenames(initialFiles, sources)
616
+ : Result.ok([] as FileRename[]);
617
+ if (renamesResult.isErr()) {
618
+ return Result.err(renamesResult.error);
619
+ }
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
+
630
+ applyRenameEffects(updatedSourceFiles, renamesResult.value);
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);
648
+ }
649
+ return Result.ok({
650
+ plannedOperations: plannedOperations.value,
651
+ renames: renamesResult.value,
652
+ updatedSourceFiles,
653
+ });
654
+ };
655
+
656
+ const resolvePromotionAppModule = async (
657
+ input: {
658
+ readonly appModule?: string | undefined;
659
+ },
660
+ rootDir: string
661
+ ): Promise<string | null> => {
662
+ const discoveredAppModule = await findTopoPath(rootDir);
663
+ return (
664
+ input.appModule ??
665
+ (discoveredAppModule === null
666
+ ? null
667
+ : toProjectModulePath(discoveredAppModule))
668
+ );
669
+ };
670
+
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,
691
+ rootDir: string
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 }> => {
705
+ if (appModule === null) {
706
+ return { load: { appModule, loadError: null }, value: null };
707
+ }
708
+
709
+ const attempt = await tryAcquireLease(appModule, rootDir);
710
+ if (!attempt.ok) {
711
+ return { load: { appModule, loadError: attempt.loadError }, value: null };
712
+ }
713
+
714
+ try {
715
+ const value = await consume(attempt.lease.app);
716
+ return { load: { appModule, loadError: null }, value };
717
+ } finally {
718
+ attempt.lease.release();
719
+ }
720
+ };
721
+
722
+ const toRenamedFiles = (rootDir: string, renames: readonly FileRename[]) =>
723
+ renames.map((rename) => ({
724
+ from: toRelativeOutputPath(rootDir, rename.from),
725
+ to: toRelativeOutputPath(rootDir, rename.to),
726
+ }));
727
+
728
+ const toUpdatedFiles = (rootDir: string, updatedSourceFiles: Set<string>) =>
729
+ [...updatedSourceFiles]
730
+ .toSorted()
731
+ .map((filePath) => toRelativeOutputPath(rootDir, filePath));
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
+
746
+ const buildUnverifiedPromotionResult = (
747
+ rootDir: string,
748
+ loadError: string | null,
749
+ renames: readonly FileRename[],
750
+ updatedSourceFiles: Set<string>,
751
+ appModule: string | null,
752
+ plannedOperations: readonly PlannedProjectOperation[],
753
+ dryRun: boolean
754
+ ) =>
755
+ Result.ok({
756
+ appModule,
757
+ dryRun,
758
+ message: buildUnverifiedPromotionMessage(loadError, dryRun),
759
+ plannedOperations,
760
+ promotedEstablished: false,
761
+ remainingDraftIds: [],
762
+ renamedFiles: toRenamedFiles(rootDir, renames),
763
+ updatedFiles: toUpdatedFiles(rootDir, updatedSourceFiles),
764
+ });
765
+
766
+ const buildVerifiedPromotionResult = (
767
+ rootDir: string,
768
+ analysis: ReturnType<typeof deriveDraftReport>,
769
+ promotedEstablished: boolean,
770
+ renames: readonly FileRename[],
771
+ updatedSourceFiles: Set<string>,
772
+ appModule: string | null,
773
+ plannedOperations: readonly PlannedProjectOperation[],
774
+ toId: string
775
+ ) => {
776
+ const blockingFinding = analysis.findings.find(
777
+ (finding) => finding.id === toId
778
+ );
779
+
780
+ return Result.ok({
781
+ appModule,
782
+ dryRun: false,
783
+ message:
784
+ blockingFinding?.message ??
785
+ (promotedEstablished
786
+ ? `Promoted "${toId}" is now established.`
787
+ : `Promoted "${toId}" could not be verified as established.`),
788
+ plannedOperations,
789
+ promotedEstablished,
790
+ remainingDraftIds: [...analysis.declaredDraftIds].toSorted(),
791
+ renamedFiles: toRenamedFiles(rootDir, renames),
792
+ updatedFiles: toUpdatedFiles(rootDir, updatedSourceFiles),
793
+ });
794
+ };
795
+
796
+ const buildVerifiedPromotionResultFromApp = (
797
+ rootDir: string,
798
+ loadedApp: Topo,
799
+ renames: readonly FileRename[],
800
+ updatedSourceFiles: Set<string>,
801
+ appModule: string | null,
802
+ plannedOperations: readonly PlannedProjectOperation[],
803
+ toId: string
804
+ ) => {
805
+ const analysis = deriveDraftReport(loadedApp);
806
+ const promotedNode = collectOutputId(loadedApp, toId);
807
+ const promotedEstablished =
808
+ promotedNode !== undefined && !analysis.contaminatedIds.has(toId);
809
+
810
+ return buildVerifiedPromotionResult(
811
+ rootDir,
812
+ analysis,
813
+ promotedEstablished,
814
+ renames,
815
+ updatedSourceFiles,
816
+ appModule,
817
+ plannedOperations,
818
+ toId
819
+ );
820
+ };
821
+
822
+ const promoteDraftState = async (
823
+ input: {
824
+ readonly appModule?: string | undefined;
825
+ readonly dryRun?: boolean | undefined;
826
+ readonly fromId: string;
827
+ readonly renameFiles: boolean;
828
+ readonly rootDir?: string | undefined;
829
+ readonly toId: string;
830
+ },
831
+ ctx: { readonly cwd?: string | undefined }
832
+ ) => {
833
+ const rootDirResult = resolveValidatedPromotionRoot(input, ctx);
834
+ if (rootDirResult.isErr()) {
835
+ return rootDirResult;
836
+ }
837
+
838
+ const rewriteResult = await rewritePromotionState(rootDirResult.value, input);
839
+ if (rewriteResult.isErr()) {
840
+ return Result.err(rewriteResult.error);
841
+ }
842
+
843
+ const { renames, updatedSourceFiles } = rewriteResult.value;
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
+
857
+ const { load, value } = await withVerifiedApp(
858
+ appModule,
859
+ rootDirResult.value,
860
+ (loadedApp) =>
861
+ buildVerifiedPromotionResultFromApp(
862
+ rootDirResult.value,
863
+ loadedApp,
864
+ renames,
865
+ updatedSourceFiles,
866
+ appModule,
867
+ rewriteResult.value.plannedOperations,
868
+ input.toId
869
+ )
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
+ );
884
+ };
885
+
886
+ export const draftPromoteTrail = trail('draft.promote', {
887
+ blaze: promoteDraftState,
888
+ description:
889
+ 'Promote a draft id to an established id, rewrite inbound references, and verify the result against a fresh topo load.',
890
+ examples: [
891
+ {
892
+ error: 'ValidationError',
893
+ input: {
894
+ // warden-ignore-next-line
895
+ fromId: '_draft.entity.prepare',
896
+ renameFiles: true,
897
+ rootDir: './__does_not_exist__/draft-promote-example',
898
+ toId: 'entity.prepare',
899
+ },
900
+ name: 'Rejects a missing project root before any rewrite begins',
901
+ },
902
+ ],
903
+ input: z.object({
904
+ appModule: z
905
+ .string()
906
+ .optional()
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'),
912
+ fromId: z.string().describe('Draft id to promote'),
913
+ renameFiles: z
914
+ .boolean()
915
+ .default(true)
916
+ .describe('Rename draft-marked files that no longer contain draft ids'),
917
+ rootDir: z.string().optional().describe('Project root directory'),
918
+ toId: z
919
+ .string()
920
+ .describe('Established id to write in place of the draft id'),
921
+ }),
922
+ intent: 'write',
923
+ output: z.object({
924
+ appModule: z.string().nullable(),
925
+ dryRun: z.boolean(),
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
+ ),
938
+ promotedEstablished: z.boolean(),
939
+ remainingDraftIds: z.array(z.string()),
940
+ renamedFiles: z.array(
941
+ z.object({
942
+ from: z.string(),
943
+ to: z.string(),
944
+ })
945
+ ),
946
+ updatedFiles: z.array(z.string()),
947
+ }),
948
+ permit: { scopes: ['version:write'] },
949
+ });