@ontrails/trails 1.0.0-beta.4 → 1.0.0-beta.41

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/CHANGELOG.md +1104 -4
  2. package/README.md +27 -0
  3. package/package.json +31 -7
  4. package/src/app.ts +147 -3
  5. package/src/cli.ts +345 -11
  6. package/src/completions.ts +240 -0
  7. package/src/lifecycle-source-io.ts +33 -0
  8. package/src/load-app-mirror.ts +202 -0
  9. package/src/local-state-io.ts +173 -0
  10. package/src/mcp-app.ts +42 -0
  11. package/src/mcp-options.ts +91 -0
  12. package/src/mcp.ts +8 -0
  13. package/src/project-writes.ts +377 -0
  14. package/src/regrade/config.ts +152 -0
  15. package/src/regrade/history.ts +430 -0
  16. package/src/regrade/live-api-preserve.ts +8 -0
  17. package/src/regrade/plan-artifact.ts +285 -0
  18. package/src/release/bindings.ts +39 -0
  19. package/src/release/check.ts +844 -0
  20. package/src/release/config.ts +63 -0
  21. package/src/release/contract-facts.ts +425 -0
  22. package/src/release/index.ts +142 -0
  23. package/src/release/lock-roundtrip-smoke.ts +230 -0
  24. package/src/release/native-bun-publish.ts +651 -0
  25. package/src/release/native-bun-registry.ts +765 -0
  26. package/src/release/notes-cli.ts +171 -0
  27. package/src/release/notes.ts +390 -0
  28. package/src/release/pack-coherence.ts +455 -0
  29. package/src/release/packed-artifacts-smoke.ts +236 -0
  30. package/src/release/policy.ts +1687 -0
  31. package/src/release/semver.ts +104 -0
  32. package/src/release/smoke.ts +56 -0
  33. package/src/release/wayfinder-dogfood-smoke.ts +760 -0
  34. package/src/retired-topo-command.ts +36 -0
  35. package/src/run-adapter-check.ts +76 -0
  36. package/src/run-collision.ts +126 -0
  37. package/src/run-completions-install.ts +179 -0
  38. package/src/run-example.ts +149 -0
  39. package/src/run-examples.ts +148 -0
  40. package/src/run-quiet.ts +75 -0
  41. package/src/run-release-check.ts +74 -0
  42. package/src/run-schema.ts +74 -0
  43. package/src/run-trace.ts +273 -0
  44. package/src/run-warden.ts +39 -0
  45. package/src/run-watch.ts +432 -0
  46. package/src/run-wayfind-outline.ts +170 -0
  47. package/src/scaffold-version-sync.ts +183 -0
  48. package/src/scaffold-versions.generated.ts +12 -0
  49. package/src/trails/adapter-check.ts +244 -0
  50. package/src/trails/add-surface.ts +99 -45
  51. package/src/trails/add-trail.ts +84 -37
  52. package/src/trails/add-verify.ts +100 -30
  53. package/src/trails/compile.ts +58 -0
  54. package/src/trails/completions-complete.ts +165 -0
  55. package/src/trails/completions.ts +47 -0
  56. package/src/trails/create-adapter.ts +785 -0
  57. package/src/trails/create-scaffold.ts +401 -106
  58. package/src/trails/create-versions.ts +62 -0
  59. package/src/trails/create.ts +186 -72
  60. package/src/trails/deprecate.ts +59 -0
  61. package/src/trails/dev-clean.ts +82 -0
  62. package/src/trails/dev-reset.ts +50 -0
  63. package/src/trails/dev-stats.ts +72 -0
  64. package/src/trails/dev-support.ts +360 -0
  65. package/src/trails/doctor.ts +77 -0
  66. package/src/trails/draft-promote.ts +949 -0
  67. package/src/trails/guide.ts +70 -68
  68. package/src/trails/load-app.ts +1123 -15
  69. package/src/trails/operator-context.ts +66 -0
  70. package/src/trails/project.ts +17 -3
  71. package/src/trails/regrade.ts +3091 -0
  72. package/src/trails/release-check.ts +105 -0
  73. package/src/trails/release-smoke.ts +49 -0
  74. package/src/trails/revise.ts +53 -0
  75. package/src/trails/root-dir.ts +21 -0
  76. package/src/trails/run-example.ts +475 -0
  77. package/src/trails/run-examples.ts +129 -0
  78. package/src/trails/run.ts +434 -0
  79. package/src/trails/scaffold-json.ts +58 -0
  80. package/src/trails/survey.ts +877 -214
  81. package/src/trails/topo-activation.ts +14 -0
  82. package/src/trails/topo-constants.ts +2 -0
  83. package/src/trails/topo-history.ts +47 -0
  84. package/src/trails/topo-output-schemas.ts +259 -0
  85. package/src/trails/topo-pin.ts +38 -0
  86. package/src/trails/topo-read-support.ts +368 -0
  87. package/src/trails/topo-reports.ts +809 -0
  88. package/src/trails/topo-store-support.ts +325 -0
  89. package/src/trails/topo-support.ts +220 -0
  90. package/src/trails/topo-unpin.ts +61 -0
  91. package/src/trails/topo.ts +92 -0
  92. package/src/trails/validate.ts +27 -0
  93. package/src/trails/version-lifecycle-support.ts +936 -0
  94. package/src/trails/warden-guide.ts +134 -0
  95. package/src/trails/warden.ts +198 -58
  96. package/src/trails/wayfind-outline.ts +876 -0
  97. package/src/trails/wayfind.ts +1053 -0
  98. package/src/versions.ts +31 -0
  99. package/.turbo/turbo-build.log +0 -1
  100. package/.turbo/turbo-lint.log +0 -3
  101. package/.turbo/turbo-typecheck.log +0 -1
  102. package/__tests__/examples.test.ts +0 -6
  103. package/dist/bin/trails.d.ts +0 -3
  104. package/dist/bin/trails.d.ts.map +0 -1
  105. package/dist/bin/trails.js +0 -4
  106. package/dist/bin/trails.js.map +0 -1
  107. package/dist/src/app.d.ts +0 -2
  108. package/dist/src/app.d.ts.map +0 -1
  109. package/dist/src/app.js +0 -11
  110. package/dist/src/app.js.map +0 -1
  111. package/dist/src/clack.d.ts +0 -9
  112. package/dist/src/clack.d.ts.map +0 -1
  113. package/dist/src/clack.js +0 -84
  114. package/dist/src/clack.js.map +0 -1
  115. package/dist/src/cli.d.ts +0 -2
  116. package/dist/src/cli.d.ts.map +0 -1
  117. package/dist/src/cli.js +0 -13
  118. package/dist/src/cli.js.map +0 -1
  119. package/dist/src/trails/add-surface.d.ts +0 -13
  120. package/dist/src/trails/add-surface.d.ts.map +0 -1
  121. package/dist/src/trails/add-surface.js +0 -88
  122. package/dist/src/trails/add-surface.js.map +0 -1
  123. package/dist/src/trails/add-trail.d.ts +0 -10
  124. package/dist/src/trails/add-trail.d.ts.map +0 -1
  125. package/dist/src/trails/add-trail.js +0 -77
  126. package/dist/src/trails/add-trail.js.map +0 -1
  127. package/dist/src/trails/add-verify.d.ts +0 -10
  128. package/dist/src/trails/add-verify.d.ts.map +0 -1
  129. package/dist/src/trails/add-verify.js +0 -67
  130. package/dist/src/trails/add-verify.js.map +0 -1
  131. package/dist/src/trails/create-scaffold.d.ts +0 -15
  132. package/dist/src/trails/create-scaffold.d.ts.map +0 -1
  133. package/dist/src/trails/create-scaffold.js +0 -288
  134. package/dist/src/trails/create-scaffold.js.map +0 -1
  135. package/dist/src/trails/create.d.ts +0 -22
  136. package/dist/src/trails/create.d.ts.map +0 -1
  137. package/dist/src/trails/create.js +0 -121
  138. package/dist/src/trails/create.js.map +0 -1
  139. package/dist/src/trails/guide.d.ts +0 -11
  140. package/dist/src/trails/guide.d.ts.map +0 -1
  141. package/dist/src/trails/guide.js +0 -80
  142. package/dist/src/trails/guide.js.map +0 -1
  143. package/dist/src/trails/load-app.d.ts +0 -4
  144. package/dist/src/trails/load-app.d.ts.map +0 -1
  145. package/dist/src/trails/load-app.js +0 -24
  146. package/dist/src/trails/load-app.js.map +0 -1
  147. package/dist/src/trails/project.d.ts +0 -8
  148. package/dist/src/trails/project.d.ts.map +0 -1
  149. package/dist/src/trails/project.js +0 -43
  150. package/dist/src/trails/project.js.map +0 -1
  151. package/dist/src/trails/survey.d.ts +0 -31
  152. package/dist/src/trails/survey.d.ts.map +0 -1
  153. package/dist/src/trails/survey.js +0 -221
  154. package/dist/src/trails/survey.js.map +0 -1
  155. package/dist/src/trails/warden.d.ts +0 -19
  156. package/dist/src/trails/warden.d.ts.map +0 -1
  157. package/dist/src/trails/warden.js +0 -88
  158. package/dist/src/trails/warden.js.map +0 -1
  159. package/dist/tsconfig.tsbuildinfo +0 -1
  160. package/src/__tests__/create.test.ts +0 -349
  161. package/src/__tests__/guide.test.ts +0 -91
  162. package/src/__tests__/load-app.test.ts +0 -15
  163. package/src/__tests__/survey.test.ts +0 -159
  164. package/src/__tests__/warden.test.ts +0 -74
  165. package/tsconfig.json +0 -9
@@ -0,0 +1,936 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ import { Result, ValidationError } from '@ontrails/core';
5
+ import type { AnyTrail, Topo } from '@ontrails/core';
6
+ import { deriveTopoGraph } from '@ontrails/topography';
7
+ import type { TopoGraph, TopoGraphForceEntry } from '@ontrails/topography';
8
+ import { findTrailDefinitions, parse } from '@ontrails/source';
9
+
10
+ import {
11
+ readLifecycleSourceFile,
12
+ writeLifecycleSourceFile,
13
+ } from '../lifecycle-source-io.js';
14
+ import { withFreshAppLease, withOperatorRootDir } from './operator-context.js';
15
+
16
+ export type LifecycleEntryKind = 'revision' | 'fork';
17
+ export type LifecycleStatusKind = 'deprecated' | 'archived';
18
+
19
+ export interface LifecycleCommandInput {
20
+ readonly module?: string | undefined;
21
+ readonly rootDir?: string | undefined;
22
+ readonly target?: string | undefined;
23
+ }
24
+
25
+ export interface LifecycleWriteResult {
26
+ readonly filePath: string;
27
+ readonly trailId: string;
28
+ readonly updated: boolean;
29
+ readonly warnings?: readonly string[] | undefined;
30
+ }
31
+
32
+ interface TrailSourceMatch {
33
+ readonly configEnd: number;
34
+ readonly configStart: number;
35
+ readonly source: string;
36
+ readonly sourcePath: string;
37
+ }
38
+
39
+ interface PropertyMatch {
40
+ readonly end: number;
41
+ readonly key: string;
42
+ readonly start: number;
43
+ readonly value: string;
44
+ readonly valueEnd: number;
45
+ readonly valueStart: number;
46
+ }
47
+
48
+ interface ParsedVersionTarget {
49
+ readonly trailId: string;
50
+ readonly version?: number | undefined;
51
+ }
52
+
53
+ const managedSourceGlob = new Bun.Glob('src/**/*.ts');
54
+
55
+ const literal = (value: string): string => JSON.stringify(value);
56
+
57
+ const parseVersionTarget = (
58
+ target: string
59
+ ): Result<ParsedVersionTarget, Error> => {
60
+ const separator = target.lastIndexOf('@');
61
+ if (separator === -1) {
62
+ return Result.ok({ trailId: target });
63
+ }
64
+ const trailId = target.slice(0, separator);
65
+ const rawVersion = target.slice(separator + 1);
66
+ const version = Number(rawVersion);
67
+ if (
68
+ trailId.length === 0 ||
69
+ !Number.isInteger(version) ||
70
+ version < 1 ||
71
+ String(version) !== rawVersion
72
+ ) {
73
+ return Result.err(
74
+ new ValidationError('Version target must use trail.id@positiveInteger')
75
+ );
76
+ }
77
+ return Result.ok({ trailId, version });
78
+ };
79
+
80
+ const scanManagedSourceFiles = (rootDir: string): readonly string[] =>
81
+ [...managedSourceGlob.scanSync({ cwd: rootDir, onlyFiles: true })]
82
+ .map((path) => join(rootDir, path))
83
+ .toSorted();
84
+
85
+ const findTrailSource = (
86
+ rootDir: string,
87
+ trailId: string
88
+ ): Result<TrailSourceMatch, Error> => {
89
+ for (const filePath of scanManagedSourceFiles(rootDir)) {
90
+ if (!existsSync(filePath)) {
91
+ continue;
92
+ }
93
+ const source = readLifecycleSourceFile(filePath);
94
+ if (source.isErr()) {
95
+ return source;
96
+ }
97
+ if (!source.value.includes(trailId)) {
98
+ continue;
99
+ }
100
+ const ast = parse(filePath, source.value);
101
+ if (!ast) {
102
+ continue;
103
+ }
104
+ const match = findTrailDefinitions(ast).find(
105
+ (definition) => definition.kind === 'trail' && definition.id === trailId
106
+ );
107
+ if (match !== undefined) {
108
+ return Result.ok({
109
+ configEnd: match.config.end,
110
+ configStart: match.config.start,
111
+ source: source.value,
112
+ sourcePath: filePath,
113
+ });
114
+ }
115
+ }
116
+
117
+ return Result.err(
118
+ new ValidationError(`Could not find source trail definition for ${trailId}`)
119
+ );
120
+ };
121
+
122
+ const isWhitespace = (ch: string | undefined): boolean =>
123
+ ch === ' ' || ch === '\n' || ch === '\r' || ch === '\t';
124
+
125
+ const scanPropertyValueEnd = (
126
+ source: string,
127
+ start: number,
128
+ end: number
129
+ ): number => {
130
+ const state: {
131
+ depth: number;
132
+ escaped: boolean;
133
+ quote: '"' | "'" | undefined;
134
+ skipNext: boolean;
135
+ templateStack: number[];
136
+ } = {
137
+ depth: 0,
138
+ escaped: false,
139
+ quote: undefined,
140
+ skipNext: false,
141
+ templateStack: [],
142
+ };
143
+ const currentTemplateDepth = (): number | undefined =>
144
+ state.templateStack.length === 0 ? undefined : state.templateStack.at(-1);
145
+ const consumeQuoted = (ch: string | undefined): boolean => {
146
+ if (state.quote === undefined) {
147
+ return false;
148
+ }
149
+ if (state.escaped) {
150
+ state.escaped = false;
151
+ } else if (ch === '\\') {
152
+ state.escaped = true;
153
+ } else if (ch === state.quote) {
154
+ state.quote = undefined;
155
+ }
156
+ return true;
157
+ };
158
+ const consumeTemplateText = (
159
+ ch: string | undefined,
160
+ next: string | undefined
161
+ ): boolean => {
162
+ if (currentTemplateDepth() !== 0) {
163
+ return false;
164
+ }
165
+ if (state.escaped) {
166
+ state.escaped = false;
167
+ } else if (ch === '\\') {
168
+ state.escaped = true;
169
+ } else if (ch === '`') {
170
+ state.templateStack.pop();
171
+ } else if (ch === '$' && next === '{') {
172
+ state.templateStack[state.templateStack.length - 1] = 1;
173
+ state.depth += 1;
174
+ state.skipNext = true;
175
+ }
176
+ return true;
177
+ };
178
+ const consumeOpen = (ch: string | undefined): boolean => {
179
+ if (ch !== '(' && ch !== '{' && ch !== '[') {
180
+ return false;
181
+ }
182
+ const templateDepth = currentTemplateDepth();
183
+ state.depth += 1;
184
+ if (templateDepth !== undefined && ch === '{') {
185
+ state.templateStack[state.templateStack.length - 1] = templateDepth + 1;
186
+ }
187
+ return true;
188
+ };
189
+ const consumeClose = (ch: string | undefined): boolean => {
190
+ if (ch !== ')' && ch !== '}' && ch !== ']') {
191
+ return false;
192
+ }
193
+ const templateDepth = currentTemplateDepth();
194
+ state.depth -= 1;
195
+ if (templateDepth !== undefined && ch === '}') {
196
+ state.templateStack[state.templateStack.length - 1] = templateDepth - 1;
197
+ }
198
+ return true;
199
+ };
200
+
201
+ for (let index = start; index < end; index += 1) {
202
+ const ch = source[index];
203
+ if (consumeQuoted(ch) || consumeTemplateText(ch, source[index + 1])) {
204
+ if (state.skipNext) {
205
+ state.skipNext = false;
206
+ index += 1;
207
+ }
208
+ continue;
209
+ }
210
+
211
+ if (ch === '"' || ch === "'") {
212
+ state.quote = ch;
213
+ continue;
214
+ }
215
+ if (ch === '`') {
216
+ state.templateStack.push(0);
217
+ continue;
218
+ }
219
+ if (consumeOpen(ch)) {
220
+ continue;
221
+ }
222
+ if (ch === ')' || ch === '}' || ch === ']') {
223
+ if (state.depth === 0) {
224
+ return index;
225
+ }
226
+ consumeClose(ch);
227
+ continue;
228
+ }
229
+ if (ch === ',' && state.depth === 0) {
230
+ return index;
231
+ }
232
+ }
233
+ let valueEnd = end;
234
+ while (valueEnd > start && isWhitespace(source[valueEnd - 1])) {
235
+ valueEnd -= 1;
236
+ }
237
+ return valueEnd;
238
+ };
239
+
240
+ const skipIgnored = (source: string, start: number, end: number): number => {
241
+ let index = start;
242
+ while (index < end) {
243
+ const ch = source[index];
244
+ if (isWhitespace(ch)) {
245
+ index += 1;
246
+ continue;
247
+ }
248
+ if (ch === '/' && source[index + 1] === '/') {
249
+ const nextLine = source.indexOf('\n', index + 2);
250
+ index = nextLine === -1 ? end : nextLine + 1;
251
+ continue;
252
+ }
253
+ if (ch === '/' && source[index + 1] === '*') {
254
+ const commentEnd = source.indexOf('*/', index + 2);
255
+ index = commentEnd === -1 ? end : commentEnd + 2;
256
+ continue;
257
+ }
258
+ break;
259
+ }
260
+ return index;
261
+ };
262
+
263
+ const isIdentifierStart = (ch: string | undefined): boolean =>
264
+ ch !== undefined && /^[A-Za-z_$]$/.test(ch);
265
+
266
+ const isIdentifierPart = (ch: string | undefined): boolean =>
267
+ ch !== undefined && /^[A-Za-z0-9_$]$/.test(ch);
268
+
269
+ const readIdentifierKey = (
270
+ source: string,
271
+ start: number
272
+ ): { readonly key: string; readonly keyEnd: number } | undefined => {
273
+ if (!isIdentifierStart(source[start])) {
274
+ return undefined;
275
+ }
276
+ let keyEnd = start + 1;
277
+ while (isIdentifierPart(source[keyEnd])) {
278
+ keyEnd += 1;
279
+ }
280
+ return { key: source.slice(start, keyEnd), keyEnd };
281
+ };
282
+
283
+ const readQuotedKey = (
284
+ source: string,
285
+ start: number,
286
+ end: number
287
+ ): { readonly key: string; readonly keyEnd: number } | undefined => {
288
+ const quote = source[start];
289
+ if (quote !== '"' && quote !== "'") {
290
+ return undefined;
291
+ }
292
+ let escaped = false;
293
+ let key = '';
294
+ for (let index = start + 1; index < end; index += 1) {
295
+ const ch = source[index];
296
+ if (escaped) {
297
+ key += ch ?? '';
298
+ escaped = false;
299
+ continue;
300
+ }
301
+ if (ch === '\\') {
302
+ escaped = true;
303
+ continue;
304
+ }
305
+ if (ch === quote) {
306
+ return { key, keyEnd: index + 1 };
307
+ }
308
+ key += ch ?? '';
309
+ }
310
+ return undefined;
311
+ };
312
+
313
+ const readNumericKey = (
314
+ source: string,
315
+ start: number
316
+ ): { readonly key: string; readonly keyEnd: number } | undefined => {
317
+ if (source[start] === undefined || !/^\d$/.test(source[start] ?? '')) {
318
+ return undefined;
319
+ }
320
+ let keyEnd = start + 1;
321
+ while (/^\d$/.test(source[keyEnd] ?? '')) {
322
+ keyEnd += 1;
323
+ }
324
+ return { key: source.slice(start, keyEnd), keyEnd };
325
+ };
326
+
327
+ const readPropertyKey = (
328
+ source: string,
329
+ start: number,
330
+ end: number
331
+ ): { readonly key: string; readonly keyEnd: number } | undefined =>
332
+ readIdentifierKey(source, start) ??
333
+ readQuotedKey(source, start, end) ??
334
+ readNumericKey(source, start);
335
+
336
+ const propertyLineStart = (source: string, keyStart: number): number => {
337
+ const lineStart = source.lastIndexOf('\n', keyStart) + 1;
338
+ return source.slice(lineStart, keyStart).trim().length === 0
339
+ ? lineStart
340
+ : keyStart;
341
+ };
342
+
343
+ const findConfigProperty = (
344
+ match: TrailSourceMatch,
345
+ key: string
346
+ ): PropertyMatch | undefined => {
347
+ const bodyStart = match.configStart + 1;
348
+ const bodyEnd =
349
+ match.source[match.configEnd - 1] === '}'
350
+ ? match.configEnd - 1
351
+ : match.configEnd;
352
+ let index = bodyStart;
353
+
354
+ while (index < bodyEnd) {
355
+ index = skipIgnored(match.source, index, bodyEnd);
356
+ if (match.source[index] === ',') {
357
+ index += 1;
358
+ continue;
359
+ }
360
+
361
+ const keyStart = index;
362
+ const propertyKey = readPropertyKey(match.source, keyStart, bodyEnd);
363
+ if (propertyKey === undefined) {
364
+ const valueEnd = scanPropertyValueEnd(match.source, keyStart, bodyEnd);
365
+ index = match.source[valueEnd] === ',' ? valueEnd + 1 : valueEnd;
366
+ continue;
367
+ }
368
+
369
+ const colon = skipIgnored(match.source, propertyKey.keyEnd, bodyEnd);
370
+ if (match.source[colon] !== ':') {
371
+ const valueEnd = scanPropertyValueEnd(match.source, keyStart, bodyEnd);
372
+ index = match.source[valueEnd] === ',' ? valueEnd + 1 : valueEnd;
373
+ continue;
374
+ }
375
+
376
+ const valueStart = colon + 1;
377
+ const valueEnd = scanPropertyValueEnd(match.source, valueStart, bodyEnd);
378
+ if (propertyKey.key === key) {
379
+ const end = match.source[valueEnd] === ',' ? valueEnd + 1 : valueEnd;
380
+ return {
381
+ end,
382
+ key,
383
+ start: propertyLineStart(match.source, keyStart),
384
+ value: match.source.slice(valueStart, valueEnd).trim(),
385
+ valueEnd,
386
+ valueStart,
387
+ };
388
+ }
389
+ index = match.source[valueEnd] === ',' ? valueEnd + 1 : valueEnd;
390
+ }
391
+
392
+ return undefined;
393
+ };
394
+
395
+ const replaceRange = (
396
+ source: string,
397
+ start: number,
398
+ end: number,
399
+ replacement: string
400
+ ): string => `${source.slice(0, start)}${replacement}${source.slice(end)}`;
401
+
402
+ const coreNamedImportPattern =
403
+ /import\s+(?<importKind>type\s+)?\{(?<imports>[^}]*)\}\s*from\s*['"]@ontrails\/core['"]/g;
404
+
405
+ const declaresRuntimeResultBinding = (specifier: string): boolean => {
406
+ const trimmed = specifier.trim();
407
+ if (trimmed.startsWith('type ')) {
408
+ return false;
409
+ }
410
+ const [imported, local] = trimmed.split(/\s+as\s+/);
411
+ return imported === 'Result' && (local === undefined || local === 'Result');
412
+ };
413
+
414
+ const hasDirectResultImport = (source: string): boolean => {
415
+ for (const match of source.matchAll(coreNamedImportPattern)) {
416
+ if (match.groups?.['importKind'] !== undefined) {
417
+ continue;
418
+ }
419
+ const imports = match.groups?.['imports'];
420
+ if (
421
+ imports
422
+ ?.split(',')
423
+ .some((specifier) => declaresRuntimeResultBinding(specifier)) === true
424
+ ) {
425
+ return true;
426
+ }
427
+ }
428
+ return false;
429
+ };
430
+
431
+ const missingResultImportWarning = (source: string): readonly string[] =>
432
+ hasDirectResultImport(source)
433
+ ? []
434
+ : [
435
+ 'Fork implementation placeholder references Result.err, but this file does not import Result from @ontrails/core.',
436
+ ];
437
+
438
+ const lineIndentAt = (source: string, index: number): string => {
439
+ const lineStart = source.lastIndexOf('\n', index) + 1;
440
+ const nextLine = source.indexOf('\n', lineStart);
441
+ const lineEnd = nextLine === -1 ? source.length : nextLine;
442
+ return /^\s*/.exec(source.slice(lineStart, lineEnd))?.[0] ?? '';
443
+ };
444
+
445
+ const propertyObjectStart = (source: string, entry: PropertyMatch): number => {
446
+ const start = source.indexOf('{', entry.valueStart);
447
+ return start === -1 ? entry.valueStart : start;
448
+ };
449
+
450
+ const propertyObjectCloseLineStart = (
451
+ source: string,
452
+ entry: PropertyMatch
453
+ ): number => {
454
+ const close = source.lastIndexOf('}', entry.valueEnd);
455
+ return source.lastIndexOf('\n', close) + 1;
456
+ };
457
+
458
+ const buildVersionEntry = (
459
+ version: number,
460
+ kind: LifecycleEntryKind,
461
+ input: string,
462
+ output: string,
463
+ implementation: string | undefined
464
+ ): string => {
465
+ if (kind === 'fork') {
466
+ return ` ${version}: {
467
+ input: ${input},
468
+ output: ${output},
469
+ implementation: ${implementation ?? 'async () => Result.err(new Error("TODO: implement fork implementation"))'},
470
+ },`;
471
+ }
472
+
473
+ return ` ${version}: {
474
+ input: ${input},
475
+ output: ${output},
476
+ transpose: {
477
+ input: ({ input }) => input as never,
478
+ output: ({ output }) => output as never,
479
+ },
480
+ },`;
481
+ };
482
+
483
+ const insertVersionEntry = (
484
+ source: string,
485
+ match: TrailSourceMatch,
486
+ entry: string
487
+ ): string => {
488
+ const versions = findConfigProperty(match, 'versions');
489
+ if (versions === undefined) {
490
+ const insertAt = match.configEnd - 1;
491
+ return replaceRange(
492
+ source,
493
+ insertAt,
494
+ insertAt,
495
+ ` versions: {\n${entry}\n },\n`
496
+ );
497
+ }
498
+
499
+ const open = source.indexOf('{', versions.valueStart);
500
+ return replaceRange(source, open + 1, open + 1, `\n${entry}`);
501
+ };
502
+
503
+ const upsertCurrentVersion = (
504
+ source: string,
505
+ match: TrailSourceMatch,
506
+ nextVersion: number
507
+ ): string => {
508
+ const existing = findConfigProperty(match, 'version');
509
+ if (existing !== undefined) {
510
+ return replaceRange(
511
+ source,
512
+ existing.valueStart,
513
+ existing.valueEnd,
514
+ ` ${nextVersion}`
515
+ );
516
+ }
517
+ const insertAt = match.configStart + 1;
518
+ return replaceRange(
519
+ source,
520
+ insertAt,
521
+ insertAt,
522
+ `\n version: ${nextVersion},`
523
+ );
524
+ };
525
+
526
+ export const reviseTrailSource = (
527
+ rootDir: string,
528
+ trail: AnyTrail,
529
+ kind: LifecycleEntryKind
530
+ ): Result<LifecycleWriteResult, Error> => {
531
+ const match = findTrailSource(rootDir, trail.id);
532
+ if (match.isErr()) {
533
+ return match;
534
+ }
535
+ const input = findConfigProperty(match.value, 'input');
536
+ const output = findConfigProperty(match.value, 'output');
537
+ if (input === undefined || output === undefined) {
538
+ return Result.err(
539
+ new ValidationError(`Trail ${trail.id} must declare input and output`)
540
+ );
541
+ }
542
+
543
+ const currentVersion = trail.version ?? 1;
544
+ const nextVersion = currentVersion + 1;
545
+ const implementation = findConfigProperty(match.value, 'implementation');
546
+ const usesForkPlaceholder =
547
+ kind === 'fork' && implementation?.value === undefined;
548
+ let nextSource = upsertCurrentVersion(
549
+ match.value.source,
550
+ match.value,
551
+ nextVersion
552
+ );
553
+ const shiftedMatch = {
554
+ ...match.value,
555
+ configEnd:
556
+ match.value.configEnd + (nextSource.length - match.value.source.length),
557
+ source: nextSource,
558
+ };
559
+ nextSource = insertVersionEntry(
560
+ nextSource,
561
+ shiftedMatch,
562
+ buildVersionEntry(
563
+ currentVersion,
564
+ kind,
565
+ input.value,
566
+ output.value,
567
+ implementation?.value
568
+ )
569
+ );
570
+ const written = writeLifecycleSourceFile(match.value.sourcePath, nextSource);
571
+ if (written.isErr()) {
572
+ return written;
573
+ }
574
+
575
+ return Result.ok({
576
+ filePath: match.value.sourcePath,
577
+ trailId: trail.id,
578
+ updated: true,
579
+ ...(usesForkPlaceholder
580
+ ? { warnings: missingResultImportWarning(match.value.source) }
581
+ : {}),
582
+ });
583
+ };
584
+
585
+ interface ForkVersionEntryRewrite {
586
+ readonly source: string;
587
+ readonly usedPlaceholder: boolean;
588
+ }
589
+
590
+ const forkVersionEntry = (
591
+ source: string,
592
+ match: TrailSourceMatch,
593
+ entry: PropertyMatch
594
+ ): ForkVersionEntryRewrite => {
595
+ const entryMatch: TrailSourceMatch = {
596
+ ...match,
597
+ configEnd: entry.valueEnd,
598
+ configStart: propertyObjectStart(source, entry),
599
+ };
600
+ const forkImplementation =
601
+ 'implementation: async () => Result.err(new Error("TODO: implement fork implementation"))';
602
+ const transpose = findConfigProperty(entryMatch, 'transpose');
603
+ if (transpose !== undefined) {
604
+ const indent = lineIndentAt(source, transpose.start);
605
+ return {
606
+ source: replaceRange(
607
+ source,
608
+ transpose.start,
609
+ transpose.end,
610
+ `${indent}${forkImplementation},`
611
+ ),
612
+ usedPlaceholder: true,
613
+ };
614
+ }
615
+ const implementation = findConfigProperty(entryMatch, 'implementation');
616
+ if (implementation !== undefined) {
617
+ return { source, usedPlaceholder: false };
618
+ }
619
+ return {
620
+ source: replaceRange(
621
+ source,
622
+ propertyObjectCloseLineStart(source, entry),
623
+ propertyObjectCloseLineStart(source, entry),
624
+ ` ${forkImplementation},\n`
625
+ ),
626
+ usedPlaceholder: true,
627
+ };
628
+ };
629
+
630
+ const statusBlock = (
631
+ status: LifecycleStatusKind,
632
+ input: {
633
+ readonly migration?: readonly string[] | undefined;
634
+ readonly note?: string | undefined;
635
+ readonly reason?: string | undefined;
636
+ readonly successor?: number | undefined;
637
+ }
638
+ ): string => {
639
+ if (status === 'archived') {
640
+ return `status: { state: 'archived'${input.reason ? `, reason: ${literal(input.reason)}` : ''} }`;
641
+ }
642
+ const migration =
643
+ input.migration === undefined || input.migration.length === 0
644
+ ? ''
645
+ : `, migration: [${input.migration.map(literal).join(', ')}]`;
646
+ const successor =
647
+ input.successor === undefined ? '' : `, successor: ${input.successor}`;
648
+ const note = input.note === undefined ? '' : `, note: ${literal(input.note)}`;
649
+ return `status: { state: 'deprecated'${successor}${migration}${note} }`;
650
+ };
651
+
652
+ const findVersionEntryProperty = (
653
+ match: TrailSourceMatch,
654
+ version: number
655
+ ): PropertyMatch | undefined => {
656
+ const versions = findConfigProperty(match, 'versions');
657
+ if (versions === undefined) {
658
+ return undefined;
659
+ }
660
+ const configStart = propertyObjectStart(match.source, versions);
661
+ const entry = findConfigProperty(
662
+ {
663
+ ...match,
664
+ configEnd: versions.valueEnd,
665
+ configStart,
666
+ },
667
+ String(version)
668
+ );
669
+ if (entry === undefined) {
670
+ return undefined;
671
+ }
672
+ return entry;
673
+ };
674
+
675
+ export const setVersionStatusSource = (
676
+ rootDir: string,
677
+ target: ParsedVersionTarget,
678
+ status: LifecycleStatusKind,
679
+ input: {
680
+ readonly migration?: readonly string[] | undefined;
681
+ readonly note?: string | undefined;
682
+ readonly reason?: string | undefined;
683
+ readonly successor?: number | undefined;
684
+ }
685
+ ): Result<LifecycleWriteResult, Error> => {
686
+ if (target.version === undefined) {
687
+ return Result.err(
688
+ new ValidationError(
689
+ 'Deprecate requires an explicit trail.id@version target'
690
+ )
691
+ );
692
+ }
693
+ const match = findTrailSource(rootDir, target.trailId);
694
+ if (match.isErr()) {
695
+ return match;
696
+ }
697
+ const entry = findVersionEntryProperty(match.value, target.version);
698
+ if (entry === undefined) {
699
+ return Result.err(
700
+ new ValidationError(
701
+ `Trail ${target.trailId} does not declare version ${target.version}`
702
+ )
703
+ );
704
+ }
705
+ const fakeEntryMatch: TrailSourceMatch = {
706
+ ...match.value,
707
+ configEnd: entry.valueEnd,
708
+ configStart: propertyObjectStart(match.value.source, entry),
709
+ };
710
+ const existing = findConfigProperty(fakeEntryMatch, 'status');
711
+ const nextStatus = statusBlock(status, input);
712
+ const nextSource =
713
+ existing === undefined
714
+ ? replaceRange(
715
+ match.value.source,
716
+ propertyObjectCloseLineStart(match.value.source, entry),
717
+ propertyObjectCloseLineStart(match.value.source, entry),
718
+ ` ${nextStatus},\n`
719
+ )
720
+ : replaceRange(
721
+ match.value.source,
722
+ existing.start,
723
+ existing.end,
724
+ `${lineIndentAt(match.value.source, existing.start)}${nextStatus},`
725
+ );
726
+ if (nextSource === match.value.source) {
727
+ return Result.ok({
728
+ filePath: match.value.sourcePath,
729
+ trailId: target.trailId,
730
+ updated: false,
731
+ });
732
+ }
733
+ const written = writeLifecycleSourceFile(match.value.sourcePath, nextSource);
734
+ if (written.isErr()) {
735
+ return written;
736
+ }
737
+
738
+ return Result.ok({
739
+ filePath: match.value.sourcePath,
740
+ trailId: target.trailId,
741
+ updated: true,
742
+ });
743
+ };
744
+
745
+ export const forkVersionEntrySource = (
746
+ rootDir: string,
747
+ target: ParsedVersionTarget
748
+ ): Result<LifecycleWriteResult, Error> => {
749
+ if (target.version === undefined) {
750
+ return Result.err(
751
+ new ValidationError(
752
+ 'Forking a historical entry requires trail.id@version'
753
+ )
754
+ );
755
+ }
756
+ const match = findTrailSource(rootDir, target.trailId);
757
+ if (match.isErr()) {
758
+ return match;
759
+ }
760
+ const entry = findVersionEntryProperty(match.value, target.version);
761
+ if (entry === undefined) {
762
+ return Result.err(
763
+ new ValidationError(
764
+ `Trail ${target.trailId} does not declare version ${target.version}`
765
+ )
766
+ );
767
+ }
768
+ const rewrite = forkVersionEntry(match.value.source, match.value, entry);
769
+ const nextSource = rewrite.source;
770
+ if (nextSource === match.value.source) {
771
+ return Result.ok({
772
+ filePath: match.value.sourcePath,
773
+ trailId: target.trailId,
774
+ updated: false,
775
+ });
776
+ }
777
+ const written = writeLifecycleSourceFile(match.value.sourcePath, nextSource);
778
+ if (written.isErr()) {
779
+ return written;
780
+ }
781
+
782
+ return Result.ok({
783
+ filePath: match.value.sourcePath,
784
+ trailId: target.trailId,
785
+ updated: true,
786
+ ...(rewrite.usedPlaceholder
787
+ ? { warnings: missingResultImportWarning(match.value.source) }
788
+ : {}),
789
+ });
790
+ };
791
+
792
+ export const parseLifecycleTarget = parseVersionTarget;
793
+
794
+ export const withLifecycleApp = async <T>(
795
+ input: LifecycleCommandInput,
796
+ cwd: string | undefined,
797
+ consume: (
798
+ app: Topo,
799
+ rootDir: string
800
+ ) => Result<T, Error> | Promise<Result<T, Error>>
801
+ ): Promise<Result<T, Error>> =>
802
+ withOperatorRootDir(input, { cwd }, (rootDir) =>
803
+ withFreshAppLease(input.module, rootDir, (lease) =>
804
+ consume(lease.app, rootDir)
805
+ )
806
+ );
807
+
808
+ export const findLifecycleTrail = (
809
+ app: Topo,
810
+ trailId: string
811
+ ): Result<AnyTrail, Error> => {
812
+ const found = app.get(trailId);
813
+ return found === undefined
814
+ ? Result.err(new ValidationError(`Trail not found: ${trailId}`))
815
+ : Result.ok(found as AnyTrail);
816
+ };
817
+
818
+ export interface DoctorForceDetail extends TopoGraphForceEntry {
819
+ readonly scope: 'entry' | 'graph';
820
+ }
821
+
822
+ export interface DoctorSummary {
823
+ readonly archived: number;
824
+ readonly deprecated: number;
825
+ readonly forceDetails: readonly DoctorForceDetail[];
826
+ readonly forceEvents: number;
827
+ readonly mode: 'doctor';
828
+ readonly trails: number;
829
+ readonly versioned: number;
830
+ }
831
+
832
+ const doctorForceKey = (force: TopoGraphForceEntry): string =>
833
+ JSON.stringify([
834
+ force.kind,
835
+ force.id,
836
+ force.change,
837
+ force.detail,
838
+ force.reason,
839
+ force.severity,
840
+ force.source,
841
+ ]);
842
+
843
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
844
+ typeof value === 'object' && value !== null && !Array.isArray(value);
845
+
846
+ const isDoctorForceEntry = (value: unknown): value is TopoGraphForceEntry =>
847
+ isRecord(value) &&
848
+ typeof value['acceptedAt'] === 'string' &&
849
+ (value['change'] === 'modified' || value['change'] === 'removed') &&
850
+ typeof value['detail'] === 'string' &&
851
+ typeof value['id'] === 'string' &&
852
+ (value['kind'] === 'entity' ||
853
+ value['kind'] === 'trail' ||
854
+ value['kind'] === 'signal' ||
855
+ value['kind'] === 'resource') &&
856
+ (value['reason'] === undefined || typeof value['reason'] === 'string') &&
857
+ value['severity'] === 'breaking' &&
858
+ value['source'] === 'trails compile --force';
859
+
860
+ const doctorForceEntries = (value: unknown): readonly TopoGraphForceEntry[] =>
861
+ Array.isArray(value) ? value.filter(isDoctorForceEntry) : [];
862
+
863
+ const pushDoctorForceDetail = (
864
+ details: DoctorForceDetail[],
865
+ seen: Set<string>,
866
+ force: TopoGraphForceEntry,
867
+ scope: DoctorForceDetail['scope']
868
+ ): void => {
869
+ const key = doctorForceKey(force);
870
+ if (seen.has(key)) {
871
+ return;
872
+ }
873
+ seen.add(key);
874
+ details.push({
875
+ acceptedAt: force.acceptedAt,
876
+ change: force.change,
877
+ detail: force.detail,
878
+ id: force.id,
879
+ kind: force.kind,
880
+ ...(force.reason === undefined ? {} : { reason: force.reason }),
881
+ scope,
882
+ severity: force.severity,
883
+ source: force.source,
884
+ });
885
+ };
886
+
887
+ const collectDoctorForceDetails = (
888
+ graph: TopoGraph
889
+ ): readonly DoctorForceDetail[] => {
890
+ const details: DoctorForceDetail[] = [];
891
+ const seen = new Set<string>();
892
+ for (const entry of graph.entries) {
893
+ for (const force of doctorForceEntries(entry.forces)) {
894
+ pushDoctorForceDetail(details, seen, force, 'entry');
895
+ }
896
+ }
897
+ for (const force of doctorForceEntries(graph.forces)) {
898
+ pushDoctorForceDetail(details, seen, force, 'graph');
899
+ }
900
+ return details;
901
+ };
902
+
903
+ export const deriveDoctorSummary = (
904
+ app: Topo,
905
+ options?: { readonly forceGraph?: TopoGraph | null | undefined }
906
+ ): DoctorSummary => {
907
+ const graph = deriveTopoGraph(app);
908
+ const forceDetails = collectDoctorForceDetails(options?.forceGraph ?? graph);
909
+ let archived = 0;
910
+ let deprecated = 0;
911
+ let versioned = 0;
912
+ for (const entry of graph.entries) {
913
+ if (entry.kind !== 'trail') {
914
+ continue;
915
+ }
916
+ if (entry.version !== undefined) {
917
+ versioned += 1;
918
+ }
919
+ for (const version of Object.values(entry.versions ?? {})) {
920
+ if (version.status?.state === 'archived') {
921
+ archived += 1;
922
+ } else if (version.status?.state === 'deprecated') {
923
+ deprecated += 1;
924
+ }
925
+ }
926
+ }
927
+ return {
928
+ archived,
929
+ deprecated,
930
+ forceDetails,
931
+ forceEvents: forceDetails.length,
932
+ mode: 'doctor',
933
+ trails: app.list().length,
934
+ versioned,
935
+ };
936
+ };