@ontrails/trails 1.0.0-beta.17 → 1.0.0-beta.19

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 (45) hide show
  1. package/CHANGELOG.md +139 -0
  2. package/README.md +7 -10
  3. package/package.json +13 -12
  4. package/src/app.ts +14 -4
  5. package/src/cli.ts +16 -0
  6. package/src/lifecycle-source-io.ts +33 -0
  7. package/src/project-writes.ts +62 -5
  8. package/src/retired-topo-command.ts +36 -0
  9. package/src/run-adapter-check.ts +76 -0
  10. package/src/run-collision.ts +1 -0
  11. package/src/trails/adapter-check.ts +244 -0
  12. package/src/trails/add-surface.ts +18 -18
  13. package/src/trails/add-trail.ts +3 -2
  14. package/src/trails/add-verify.ts +30 -6
  15. package/src/trails/{topo-compile.ts → compile.ts} +16 -8
  16. package/src/trails/completions-complete.ts +1 -1
  17. package/src/trails/create-adapter.ts +1084 -0
  18. package/src/trails/create-scaffold.ts +243 -29
  19. package/src/trails/create.ts +118 -17
  20. package/src/trails/deprecate.ts +59 -0
  21. package/src/trails/dev-clean.ts +2 -2
  22. package/src/trails/dev-reset.ts +2 -2
  23. package/src/trails/dev-stats.ts +1 -1
  24. package/src/trails/doctor.ts +56 -0
  25. package/src/trails/draft-promote.ts +1 -0
  26. package/src/trails/guide.ts +2 -2
  27. package/src/trails/revise.ts +53 -0
  28. package/src/trails/run-example.ts +12 -7
  29. package/src/trails/run-examples.ts +3 -3
  30. package/src/trails/run.ts +7 -4
  31. package/src/trails/survey.ts +332 -25
  32. package/src/trails/topo-history.ts +1 -1
  33. package/src/trails/topo-output-schemas.ts +30 -1
  34. package/src/trails/topo-pin.ts +3 -2
  35. package/src/trails/topo-read-support.ts +49 -8
  36. package/src/trails/topo-reports.ts +39 -22
  37. package/src/trails/topo-store-support.ts +62 -16
  38. package/src/trails/topo-support.ts +1 -1
  39. package/src/trails/topo-unpin.ts +2 -2
  40. package/src/trails/topo.ts +2 -2
  41. package/src/trails/{topo-verify.ts → validate.ts} +7 -7
  42. package/src/trails/version-lifecycle-support.ts +945 -0
  43. package/src/trails/warden-guide.ts +8 -0
  44. package/src/trails/warden.ts +18 -2
  45. package/src/versions.ts +4 -1
@@ -0,0 +1,945 @@
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/topographer';
7
+ import type { TopoGraph, TopoGraphForceEntry } from '@ontrails/topographer';
8
+ import { findTrailDefinitions, parse } from '@ontrails/warden/ast';
9
+
10
+ import { tryLoadFreshAppLease } from './load-app.js';
11
+ import {
12
+ readLifecycleSourceFile,
13
+ writeLifecycleSourceFile,
14
+ } from '../lifecycle-source-io.js';
15
+ import { resolveTrailRootDir } from './root-dir.js';
16
+
17
+ export type LifecycleEntryKind = 'revision' | 'fork';
18
+ export type LifecycleStatusKind = 'deprecated' | 'archived';
19
+
20
+ export interface LifecycleCommandInput {
21
+ readonly module?: string | undefined;
22
+ readonly rootDir?: string | undefined;
23
+ readonly target?: string | undefined;
24
+ }
25
+
26
+ export interface LifecycleWriteResult {
27
+ readonly file: string;
28
+ readonly trailId: string;
29
+ readonly updated: boolean;
30
+ readonly warnings?: readonly string[] | undefined;
31
+ }
32
+
33
+ interface TrailSourceMatch {
34
+ readonly configEnd: number;
35
+ readonly configStart: number;
36
+ readonly filePath: string;
37
+ readonly source: string;
38
+ }
39
+
40
+ interface PropertyMatch {
41
+ readonly end: number;
42
+ readonly key: string;
43
+ readonly start: number;
44
+ readonly value: string;
45
+ readonly valueEnd: number;
46
+ readonly valueStart: number;
47
+ }
48
+
49
+ interface ParsedVersionTarget {
50
+ readonly trailId: string;
51
+ readonly version?: number | undefined;
52
+ }
53
+
54
+ const managedSourceGlob = new Bun.Glob('src/**/*.ts');
55
+
56
+ const literal = (value: string): string => JSON.stringify(value);
57
+
58
+ const parseVersionTarget = (
59
+ target: string
60
+ ): Result<ParsedVersionTarget, Error> => {
61
+ const separator = target.lastIndexOf('@');
62
+ if (separator === -1) {
63
+ return Result.ok({ trailId: target });
64
+ }
65
+ const trailId = target.slice(0, separator);
66
+ const rawVersion = target.slice(separator + 1);
67
+ const version = Number(rawVersion);
68
+ if (
69
+ trailId.length === 0 ||
70
+ !Number.isInteger(version) ||
71
+ version < 1 ||
72
+ String(version) !== rawVersion
73
+ ) {
74
+ return Result.err(
75
+ new ValidationError('Version target must use trail.id@positiveInteger')
76
+ );
77
+ }
78
+ return Result.ok({ trailId, version });
79
+ };
80
+
81
+ const scanManagedSourceFiles = (rootDir: string): readonly string[] =>
82
+ [...managedSourceGlob.scanSync({ cwd: rootDir, onlyFiles: true })]
83
+ .map((path) => join(rootDir, path))
84
+ .toSorted();
85
+
86
+ const findTrailSource = (
87
+ rootDir: string,
88
+ trailId: string
89
+ ): Result<TrailSourceMatch, Error> => {
90
+ for (const filePath of scanManagedSourceFiles(rootDir)) {
91
+ if (!existsSync(filePath)) {
92
+ continue;
93
+ }
94
+ const source = readLifecycleSourceFile(filePath);
95
+ if (source.isErr()) {
96
+ return source;
97
+ }
98
+ if (!source.value.includes(trailId)) {
99
+ continue;
100
+ }
101
+ const ast = parse(filePath, source.value);
102
+ if (!ast) {
103
+ continue;
104
+ }
105
+ const match = findTrailDefinitions(ast).find(
106
+ (definition) => definition.kind === 'trail' && definition.id === trailId
107
+ );
108
+ if (match !== undefined) {
109
+ return Result.ok({
110
+ configEnd: match.config.end,
111
+ configStart: match.config.start,
112
+ filePath,
113
+ source: source.value,
114
+ });
115
+ }
116
+ }
117
+
118
+ return Result.err(
119
+ new ValidationError(`Could not find source trail definition for ${trailId}`)
120
+ );
121
+ };
122
+
123
+ const isWhitespace = (ch: string | undefined): boolean =>
124
+ ch === ' ' || ch === '\n' || ch === '\r' || ch === '\t';
125
+
126
+ const scanPropertyValueEnd = (
127
+ source: string,
128
+ start: number,
129
+ end: number
130
+ ): number => {
131
+ const state: {
132
+ depth: number;
133
+ escaped: boolean;
134
+ quote: '"' | "'" | undefined;
135
+ skipNext: boolean;
136
+ templateStack: number[];
137
+ } = {
138
+ depth: 0,
139
+ escaped: false,
140
+ quote: undefined,
141
+ skipNext: false,
142
+ templateStack: [],
143
+ };
144
+ const currentTemplateDepth = (): number | undefined =>
145
+ state.templateStack.length === 0 ? undefined : state.templateStack.at(-1);
146
+ const consumeQuoted = (ch: string | undefined): boolean => {
147
+ if (state.quote === undefined) {
148
+ return false;
149
+ }
150
+ if (state.escaped) {
151
+ state.escaped = false;
152
+ } else if (ch === '\\') {
153
+ state.escaped = true;
154
+ } else if (ch === state.quote) {
155
+ state.quote = undefined;
156
+ }
157
+ return true;
158
+ };
159
+ const consumeTemplateText = (
160
+ ch: string | undefined,
161
+ next: string | undefined
162
+ ): boolean => {
163
+ if (currentTemplateDepth() !== 0) {
164
+ return false;
165
+ }
166
+ if (state.escaped) {
167
+ state.escaped = false;
168
+ } else if (ch === '\\') {
169
+ state.escaped = true;
170
+ } else if (ch === '`') {
171
+ state.templateStack.pop();
172
+ } else if (ch === '$' && next === '{') {
173
+ state.templateStack[state.templateStack.length - 1] = 1;
174
+ state.depth += 1;
175
+ state.skipNext = true;
176
+ }
177
+ return true;
178
+ };
179
+ const consumeOpen = (ch: string | undefined): boolean => {
180
+ if (ch !== '(' && ch !== '{' && ch !== '[') {
181
+ return false;
182
+ }
183
+ const templateDepth = currentTemplateDepth();
184
+ state.depth += 1;
185
+ if (templateDepth !== undefined && ch === '{') {
186
+ state.templateStack[state.templateStack.length - 1] = templateDepth + 1;
187
+ }
188
+ return true;
189
+ };
190
+ const consumeClose = (ch: string | undefined): boolean => {
191
+ if (ch !== ')' && ch !== '}' && ch !== ']') {
192
+ return false;
193
+ }
194
+ const templateDepth = currentTemplateDepth();
195
+ state.depth -= 1;
196
+ if (templateDepth !== undefined && ch === '}') {
197
+ state.templateStack[state.templateStack.length - 1] = templateDepth - 1;
198
+ }
199
+ return true;
200
+ };
201
+
202
+ for (let index = start; index < end; index += 1) {
203
+ const ch = source[index];
204
+ if (consumeQuoted(ch) || consumeTemplateText(ch, source[index + 1])) {
205
+ if (state.skipNext) {
206
+ state.skipNext = false;
207
+ index += 1;
208
+ }
209
+ continue;
210
+ }
211
+
212
+ if (ch === '"' || ch === "'") {
213
+ state.quote = ch;
214
+ continue;
215
+ }
216
+ if (ch === '`') {
217
+ state.templateStack.push(0);
218
+ continue;
219
+ }
220
+ if (consumeOpen(ch)) {
221
+ continue;
222
+ }
223
+ if (ch === ')' || ch === '}' || ch === ']') {
224
+ if (state.depth === 0) {
225
+ return index;
226
+ }
227
+ consumeClose(ch);
228
+ continue;
229
+ }
230
+ if (ch === ',' && state.depth === 0) {
231
+ return index;
232
+ }
233
+ }
234
+ let valueEnd = end;
235
+ while (valueEnd > start && isWhitespace(source[valueEnd - 1])) {
236
+ valueEnd -= 1;
237
+ }
238
+ return valueEnd;
239
+ };
240
+
241
+ const skipIgnored = (source: string, start: number, end: number): number => {
242
+ let index = start;
243
+ while (index < end) {
244
+ const ch = source[index];
245
+ if (isWhitespace(ch)) {
246
+ index += 1;
247
+ continue;
248
+ }
249
+ if (ch === '/' && source[index + 1] === '/') {
250
+ const nextLine = source.indexOf('\n', index + 2);
251
+ index = nextLine === -1 ? end : nextLine + 1;
252
+ continue;
253
+ }
254
+ if (ch === '/' && source[index + 1] === '*') {
255
+ const commentEnd = source.indexOf('*/', index + 2);
256
+ index = commentEnd === -1 ? end : commentEnd + 2;
257
+ continue;
258
+ }
259
+ break;
260
+ }
261
+ return index;
262
+ };
263
+
264
+ const isIdentifierStart = (ch: string | undefined): boolean =>
265
+ ch !== undefined && /^[A-Za-z_$]$/.test(ch);
266
+
267
+ const isIdentifierPart = (ch: string | undefined): boolean =>
268
+ ch !== undefined && /^[A-Za-z0-9_$]$/.test(ch);
269
+
270
+ const readIdentifierKey = (
271
+ source: string,
272
+ start: number
273
+ ): { readonly key: string; readonly keyEnd: number } | undefined => {
274
+ if (!isIdentifierStart(source[start])) {
275
+ return undefined;
276
+ }
277
+ let keyEnd = start + 1;
278
+ while (isIdentifierPart(source[keyEnd])) {
279
+ keyEnd += 1;
280
+ }
281
+ return { key: source.slice(start, keyEnd), keyEnd };
282
+ };
283
+
284
+ const readQuotedKey = (
285
+ source: string,
286
+ start: number,
287
+ end: number
288
+ ): { readonly key: string; readonly keyEnd: number } | undefined => {
289
+ const quote = source[start];
290
+ if (quote !== '"' && quote !== "'") {
291
+ return undefined;
292
+ }
293
+ let escaped = false;
294
+ let key = '';
295
+ for (let index = start + 1; index < end; index += 1) {
296
+ const ch = source[index];
297
+ if (escaped) {
298
+ key += ch ?? '';
299
+ escaped = false;
300
+ continue;
301
+ }
302
+ if (ch === '\\') {
303
+ escaped = true;
304
+ continue;
305
+ }
306
+ if (ch === quote) {
307
+ return { key, keyEnd: index + 1 };
308
+ }
309
+ key += ch ?? '';
310
+ }
311
+ return undefined;
312
+ };
313
+
314
+ const readNumericKey = (
315
+ source: string,
316
+ start: number
317
+ ): { readonly key: string; readonly keyEnd: number } | undefined => {
318
+ if (source[start] === undefined || !/^\d$/.test(source[start] ?? '')) {
319
+ return undefined;
320
+ }
321
+ let keyEnd = start + 1;
322
+ while (/^\d$/.test(source[keyEnd] ?? '')) {
323
+ keyEnd += 1;
324
+ }
325
+ return { key: source.slice(start, keyEnd), keyEnd };
326
+ };
327
+
328
+ const readPropertyKey = (
329
+ source: string,
330
+ start: number,
331
+ end: number
332
+ ): { readonly key: string; readonly keyEnd: number } | undefined =>
333
+ readIdentifierKey(source, start) ??
334
+ readQuotedKey(source, start, end) ??
335
+ readNumericKey(source, start);
336
+
337
+ const propertyLineStart = (source: string, keyStart: number): number => {
338
+ const lineStart = source.lastIndexOf('\n', keyStart) + 1;
339
+ return source.slice(lineStart, keyStart).trim().length === 0
340
+ ? lineStart
341
+ : keyStart;
342
+ };
343
+
344
+ const findConfigProperty = (
345
+ match: TrailSourceMatch,
346
+ key: string
347
+ ): PropertyMatch | undefined => {
348
+ const bodyStart = match.configStart + 1;
349
+ const bodyEnd =
350
+ match.source[match.configEnd - 1] === '}'
351
+ ? match.configEnd - 1
352
+ : match.configEnd;
353
+ let index = bodyStart;
354
+
355
+ while (index < bodyEnd) {
356
+ index = skipIgnored(match.source, index, bodyEnd);
357
+ if (match.source[index] === ',') {
358
+ index += 1;
359
+ continue;
360
+ }
361
+
362
+ const keyStart = index;
363
+ const propertyKey = readPropertyKey(match.source, keyStart, bodyEnd);
364
+ if (propertyKey === undefined) {
365
+ const valueEnd = scanPropertyValueEnd(match.source, keyStart, bodyEnd);
366
+ index = match.source[valueEnd] === ',' ? valueEnd + 1 : valueEnd;
367
+ continue;
368
+ }
369
+
370
+ const colon = skipIgnored(match.source, propertyKey.keyEnd, bodyEnd);
371
+ if (match.source[colon] !== ':') {
372
+ const valueEnd = scanPropertyValueEnd(match.source, keyStart, bodyEnd);
373
+ index = match.source[valueEnd] === ',' ? valueEnd + 1 : valueEnd;
374
+ continue;
375
+ }
376
+
377
+ const valueStart = colon + 1;
378
+ const valueEnd = scanPropertyValueEnd(match.source, valueStart, bodyEnd);
379
+ if (propertyKey.key === key) {
380
+ const end = match.source[valueEnd] === ',' ? valueEnd + 1 : valueEnd;
381
+ return {
382
+ end,
383
+ key,
384
+ start: propertyLineStart(match.source, keyStart),
385
+ value: match.source.slice(valueStart, valueEnd).trim(),
386
+ valueEnd,
387
+ valueStart,
388
+ };
389
+ }
390
+ index = match.source[valueEnd] === ',' ? valueEnd + 1 : valueEnd;
391
+ }
392
+
393
+ return undefined;
394
+ };
395
+
396
+ const replaceRange = (
397
+ source: string,
398
+ start: number,
399
+ end: number,
400
+ replacement: string
401
+ ): string => `${source.slice(0, start)}${replacement}${source.slice(end)}`;
402
+
403
+ const coreNamedImportPattern =
404
+ /import\s+(?<importKind>type\s+)?\{(?<imports>[^}]*)\}\s*from\s*['"]@ontrails\/core['"]/g;
405
+
406
+ const declaresRuntimeResultBinding = (specifier: string): boolean => {
407
+ const trimmed = specifier.trim();
408
+ if (trimmed.startsWith('type ')) {
409
+ return false;
410
+ }
411
+ const [imported, local] = trimmed.split(/\s+as\s+/);
412
+ return imported === 'Result' && (local === undefined || local === 'Result');
413
+ };
414
+
415
+ const hasDirectResultImport = (source: string): boolean => {
416
+ for (const match of source.matchAll(coreNamedImportPattern)) {
417
+ if (match.groups?.['importKind'] !== undefined) {
418
+ continue;
419
+ }
420
+ const imports = match.groups?.['imports'];
421
+ if (
422
+ imports
423
+ ?.split(',')
424
+ .some((specifier) => declaresRuntimeResultBinding(specifier)) === true
425
+ ) {
426
+ return true;
427
+ }
428
+ }
429
+ return false;
430
+ };
431
+
432
+ const missingResultImportWarning = (source: string): readonly string[] =>
433
+ hasDirectResultImport(source)
434
+ ? []
435
+ : [
436
+ 'Fork blaze placeholder references Result.err, but this file does not import Result from @ontrails/core.',
437
+ ];
438
+
439
+ const lineIndentAt = (source: string, index: number): string => {
440
+ const lineStart = source.lastIndexOf('\n', index) + 1;
441
+ const nextLine = source.indexOf('\n', lineStart);
442
+ const lineEnd = nextLine === -1 ? source.length : nextLine;
443
+ return /^\s*/.exec(source.slice(lineStart, lineEnd))?.[0] ?? '';
444
+ };
445
+
446
+ const propertyObjectStart = (source: string, entry: PropertyMatch): number => {
447
+ const start = source.indexOf('{', entry.valueStart);
448
+ return start === -1 ? entry.valueStart : start;
449
+ };
450
+
451
+ const propertyObjectCloseLineStart = (
452
+ source: string,
453
+ entry: PropertyMatch
454
+ ): number => {
455
+ const close = source.lastIndexOf('}', entry.valueEnd);
456
+ return source.lastIndexOf('\n', close) + 1;
457
+ };
458
+
459
+ const buildVersionEntry = (
460
+ version: number,
461
+ kind: LifecycleEntryKind,
462
+ input: string,
463
+ output: string,
464
+ blaze: string | undefined
465
+ ): string => {
466
+ if (kind === 'fork') {
467
+ return ` ${version}: {
468
+ input: ${input},
469
+ output: ${output},
470
+ blaze: ${blaze ?? 'async () => Result.err(new Error("TODO: implement fork blaze"))'},
471
+ },`;
472
+ }
473
+
474
+ return ` ${version}: {
475
+ input: ${input},
476
+ output: ${output},
477
+ transpose: {
478
+ input: ({ input }) => input as never,
479
+ output: ({ output }) => output as never,
480
+ },
481
+ },`;
482
+ };
483
+
484
+ const insertVersionEntry = (
485
+ source: string,
486
+ match: TrailSourceMatch,
487
+ entry: string
488
+ ): string => {
489
+ const versions = findConfigProperty(match, 'versions');
490
+ if (versions === undefined) {
491
+ const insertAt = match.configEnd - 1;
492
+ return replaceRange(
493
+ source,
494
+ insertAt,
495
+ insertAt,
496
+ ` versions: {\n${entry}\n },\n`
497
+ );
498
+ }
499
+
500
+ const open = source.indexOf('{', versions.valueStart);
501
+ return replaceRange(source, open + 1, open + 1, `\n${entry}`);
502
+ };
503
+
504
+ const upsertCurrentVersion = (
505
+ source: string,
506
+ match: TrailSourceMatch,
507
+ nextVersion: number
508
+ ): string => {
509
+ const existing = findConfigProperty(match, 'version');
510
+ if (existing !== undefined) {
511
+ return replaceRange(
512
+ source,
513
+ existing.valueStart,
514
+ existing.valueEnd,
515
+ ` ${nextVersion}`
516
+ );
517
+ }
518
+ const insertAt = match.configStart + 1;
519
+ return replaceRange(
520
+ source,
521
+ insertAt,
522
+ insertAt,
523
+ `\n version: ${nextVersion},`
524
+ );
525
+ };
526
+
527
+ export const reviseTrailSource = (
528
+ rootDir: string,
529
+ trail: AnyTrail,
530
+ kind: LifecycleEntryKind
531
+ ): Result<LifecycleWriteResult, Error> => {
532
+ const match = findTrailSource(rootDir, trail.id);
533
+ if (match.isErr()) {
534
+ return match;
535
+ }
536
+ const input = findConfigProperty(match.value, 'input');
537
+ const output = findConfigProperty(match.value, 'output');
538
+ if (input === undefined || output === undefined) {
539
+ return Result.err(
540
+ new ValidationError(`Trail ${trail.id} must declare input and output`)
541
+ );
542
+ }
543
+
544
+ const currentVersion = trail.version ?? 1;
545
+ const nextVersion = currentVersion + 1;
546
+ const blaze = findConfigProperty(match.value, 'blaze');
547
+ const usesForkPlaceholder = kind === 'fork' && blaze?.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
+ blaze?.value
568
+ )
569
+ );
570
+ const written = writeLifecycleSourceFile(match.value.filePath, nextSource);
571
+ if (written.isErr()) {
572
+ return Result.err(written.error);
573
+ }
574
+
575
+ return Result.ok({
576
+ file: match.value.filePath,
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 forkBlaze =
601
+ 'blaze: async () => Result.err(new Error("TODO: implement fork blaze"))';
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}${forkBlaze},`
611
+ ),
612
+ usedPlaceholder: true,
613
+ };
614
+ }
615
+ const blaze = findConfigProperty(entryMatch, 'blaze');
616
+ if (blaze !== undefined) {
617
+ return { source, usedPlaceholder: false };
618
+ }
619
+ return {
620
+ source: replaceRange(
621
+ source,
622
+ propertyObjectCloseLineStart(source, entry),
623
+ propertyObjectCloseLineStart(source, entry),
624
+ ` ${forkBlaze},\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
+ file: match.value.filePath,
729
+ trailId: target.trailId,
730
+ updated: false,
731
+ });
732
+ }
733
+ const written = writeLifecycleSourceFile(match.value.filePath, nextSource);
734
+ if (written.isErr()) {
735
+ return Result.err(written.error);
736
+ }
737
+
738
+ return Result.ok({
739
+ file: match.value.filePath,
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
+ file: match.value.filePath,
773
+ trailId: target.trailId,
774
+ updated: false,
775
+ });
776
+ }
777
+ const written = writeLifecycleSourceFile(match.value.filePath, nextSource);
778
+ if (written.isErr()) {
779
+ return Result.err(written.error);
780
+ }
781
+
782
+ return Result.ok({
783
+ file: match.value.filePath,
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
+ const root = resolveTrailRootDir(input.rootDir, cwd);
803
+ if (root.isErr()) {
804
+ return root;
805
+ }
806
+ const lease = await tryLoadFreshAppLease(input.module, root.value);
807
+ if (lease.isErr()) {
808
+ return Result.err(lease.error);
809
+ }
810
+ try {
811
+ return await consume(lease.value.app, root.value);
812
+ } finally {
813
+ lease.value.release();
814
+ }
815
+ };
816
+
817
+ export const findLifecycleTrail = (
818
+ app: Topo,
819
+ trailId: string
820
+ ): Result<AnyTrail, Error> => {
821
+ const found = app.get(trailId);
822
+ return found === undefined
823
+ ? Result.err(new ValidationError(`Trail not found: ${trailId}`))
824
+ : Result.ok(found as AnyTrail);
825
+ };
826
+
827
+ export interface DoctorForceDetail extends TopoGraphForceEntry {
828
+ readonly scope: 'entry' | 'graph';
829
+ }
830
+
831
+ export interface DoctorSummary {
832
+ readonly archived: number;
833
+ readonly deprecated: number;
834
+ readonly forceDetails: readonly DoctorForceDetail[];
835
+ readonly forceEvents: number;
836
+ readonly mode: 'doctor';
837
+ readonly trails: number;
838
+ readonly versioned: number;
839
+ }
840
+
841
+ const doctorForceKey = (force: TopoGraphForceEntry): string =>
842
+ JSON.stringify([
843
+ force.kind,
844
+ force.id,
845
+ force.change,
846
+ force.detail,
847
+ force.reason,
848
+ force.severity,
849
+ force.source,
850
+ ]);
851
+
852
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
853
+ typeof value === 'object' && value !== null && !Array.isArray(value);
854
+
855
+ const isDoctorForceEntry = (value: unknown): value is TopoGraphForceEntry =>
856
+ isRecord(value) &&
857
+ typeof value['acceptedAt'] === 'string' &&
858
+ (value['change'] === 'modified' || value['change'] === 'removed') &&
859
+ typeof value['detail'] === 'string' &&
860
+ typeof value['id'] === 'string' &&
861
+ (value['kind'] === 'contour' ||
862
+ value['kind'] === 'trail' ||
863
+ value['kind'] === 'signal' ||
864
+ value['kind'] === 'resource') &&
865
+ (value['reason'] === undefined || typeof value['reason'] === 'string') &&
866
+ value['severity'] === 'breaking' &&
867
+ value['source'] === 'trails compile --force';
868
+
869
+ const doctorForceEntries = (value: unknown): readonly TopoGraphForceEntry[] =>
870
+ Array.isArray(value) ? value.filter(isDoctorForceEntry) : [];
871
+
872
+ const pushDoctorForceDetail = (
873
+ details: DoctorForceDetail[],
874
+ seen: Set<string>,
875
+ force: TopoGraphForceEntry,
876
+ scope: DoctorForceDetail['scope']
877
+ ): void => {
878
+ const key = doctorForceKey(force);
879
+ if (seen.has(key)) {
880
+ return;
881
+ }
882
+ seen.add(key);
883
+ details.push({
884
+ acceptedAt: force.acceptedAt,
885
+ change: force.change,
886
+ detail: force.detail,
887
+ id: force.id,
888
+ kind: force.kind,
889
+ ...(force.reason === undefined ? {} : { reason: force.reason }),
890
+ scope,
891
+ severity: force.severity,
892
+ source: force.source,
893
+ });
894
+ };
895
+
896
+ const collectDoctorForceDetails = (
897
+ graph: TopoGraph
898
+ ): readonly DoctorForceDetail[] => {
899
+ const details: DoctorForceDetail[] = [];
900
+ const seen = new Set<string>();
901
+ for (const entry of graph.entries) {
902
+ for (const force of doctorForceEntries(entry.forces)) {
903
+ pushDoctorForceDetail(details, seen, force, 'entry');
904
+ }
905
+ }
906
+ for (const force of doctorForceEntries(graph.forces)) {
907
+ pushDoctorForceDetail(details, seen, force, 'graph');
908
+ }
909
+ return details;
910
+ };
911
+
912
+ export const deriveDoctorSummary = (
913
+ app: Topo,
914
+ options?: { readonly forceGraph?: TopoGraph | null | undefined }
915
+ ): DoctorSummary => {
916
+ const graph = deriveTopoGraph(app);
917
+ const forceDetails = collectDoctorForceDetails(options?.forceGraph ?? graph);
918
+ let archived = 0;
919
+ let deprecated = 0;
920
+ let versioned = 0;
921
+ for (const entry of graph.entries) {
922
+ if (entry.kind !== 'trail') {
923
+ continue;
924
+ }
925
+ if (entry.version !== undefined) {
926
+ versioned += 1;
927
+ }
928
+ for (const version of Object.values(entry.versions ?? {})) {
929
+ if (version.status?.state === 'archived') {
930
+ archived += 1;
931
+ } else if (version.status?.state === 'deprecated') {
932
+ deprecated += 1;
933
+ }
934
+ }
935
+ }
936
+ return {
937
+ archived,
938
+ deprecated,
939
+ forceDetails,
940
+ forceEvents: forceDetails.length,
941
+ mode: 'doctor',
942
+ trails: app.list().length,
943
+ versioned,
944
+ };
945
+ };