@ontrails/trails 1.0.0-beta.39 → 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 (58) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/README.md +1 -1
  3. package/package.json +17 -17
  4. package/src/app.ts +4 -4
  5. package/src/cli.ts +1 -1
  6. package/src/completions.ts +1 -1
  7. package/src/mcp-app.ts +2 -2
  8. package/src/regrade/history.ts +148 -38
  9. package/src/regrade/plan-artifact.ts +31 -11
  10. package/src/release/native-bun-registry.ts +12 -1
  11. package/src/release/policy.ts +4 -1
  12. package/src/retired-topo-command.ts +1 -1
  13. package/src/run-watch.ts +1 -1
  14. package/src/run-wayfind-outline.ts +6 -2
  15. package/src/trails/adapter-check.ts +8 -8
  16. package/src/trails/add-surface.ts +2 -2
  17. package/src/trails/add-trail.ts +3 -3
  18. package/src/trails/add-verify.ts +2 -2
  19. package/src/trails/compile.ts +9 -9
  20. package/src/trails/completions-complete.ts +10 -10
  21. package/src/trails/completions.ts +2 -2
  22. package/src/trails/create-adapter.ts +185 -424
  23. package/src/trails/create-scaffold.ts +12 -12
  24. package/src/trails/create-versions.ts +8 -8
  25. package/src/trails/create.ts +35 -35
  26. package/src/trails/deprecate.ts +2 -2
  27. package/src/trails/dev-clean.ts +11 -11
  28. package/src/trails/dev-reset.ts +11 -11
  29. package/src/trails/dev-stats.ts +8 -8
  30. package/src/trails/dev-support.ts +1 -1
  31. package/src/trails/doctor.ts +4 -4
  32. package/src/trails/draft-promote.ts +3 -3
  33. package/src/trails/guide.ts +9 -9
  34. package/src/trails/load-app.ts +3 -3
  35. package/src/trails/regrade.ts +56 -27
  36. package/src/trails/release-check.ts +8 -8
  37. package/src/trails/release-smoke.ts +2 -2
  38. package/src/trails/revise.ts +2 -2
  39. package/src/trails/run-example.ts +9 -9
  40. package/src/trails/run-examples.ts +9 -9
  41. package/src/trails/run.ts +26 -26
  42. package/src/trails/survey.ts +45 -45
  43. package/src/trails/topo-activation.ts +2 -2
  44. package/src/trails/topo-history.ts +8 -8
  45. package/src/trails/topo-output-schemas.ts +5 -5
  46. package/src/trails/topo-pin.ts +6 -6
  47. package/src/trails/topo-read-support.ts +4 -3
  48. package/src/trails/topo-reports.ts +16 -16
  49. package/src/trails/topo-store-support.ts +18 -9
  50. package/src/trails/topo-support.ts +2 -2
  51. package/src/trails/topo-unpin.ts +12 -12
  52. package/src/trails/topo.ts +4 -4
  53. package/src/trails/validate.ts +2 -2
  54. package/src/trails/version-lifecycle-support.ts +17 -16
  55. package/src/trails/warden-guide.ts +8 -8
  56. package/src/trails/warden.ts +21 -21
  57. package/src/trails/wayfind-outline.ts +876 -0
  58. package/src/trails/wayfind.ts +74 -74
@@ -0,0 +1,876 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { relative, resolve } from 'node:path';
3
+
4
+ import {
5
+ DerivationError,
6
+ NotFoundError,
7
+ Result,
8
+ ValidationError,
9
+ securePath,
10
+ trail,
11
+ } from '@ontrails/core';
12
+ import type { TrailsError } from '@ontrails/core';
13
+ import { loadWayfinderArtifacts } from '@ontrails/topography';
14
+ import type {
15
+ TopoGraph,
16
+ TopoGraphEntry,
17
+ WayfinderArtifactLoad,
18
+ } from '@ontrails/topography';
19
+ import {
20
+ findTrailDefinitions,
21
+ getStringValue,
22
+ identifierName,
23
+ isCallExpression,
24
+ isClassMember,
25
+ isDeclarationWithId,
26
+ isExportAllDeclaration,
27
+ isExportDeclaration,
28
+ isExportDefaultDeclaration,
29
+ isExportNamedDeclaration,
30
+ isExportSpecifier,
31
+ isIdentifier,
32
+ isImportDeclaration,
33
+ isImportSpecifier,
34
+ isMemberExpression,
35
+ isProgram,
36
+ isVariableDeclaration,
37
+ isVariableDeclarator,
38
+ offsetToLineColumn,
39
+ parseWithDiagnostics,
40
+ walkWithParents,
41
+ } from '@ontrails/source';
42
+ import type { AstNode, AstParentContext } from '@ontrails/source';
43
+ import { z } from 'zod';
44
+
45
+ const outlineFeatureNames = [
46
+ 'source',
47
+ 'trails',
48
+ 'apps',
49
+ 'contracts',
50
+ 'surfaces',
51
+ 'graph',
52
+ 'diagnostics',
53
+ ] as const;
54
+
55
+ const defaultOutlineFeatures = [
56
+ 'trails',
57
+ 'apps',
58
+ 'surfaces',
59
+ 'graph',
60
+ 'diagnostics',
61
+ ] as const;
62
+
63
+ const outlineViewFeatures = {
64
+ all: outlineFeatureNames,
65
+ contracts: ['trails', 'contracts', 'graph', 'diagnostics'],
66
+ default: defaultOutlineFeatures,
67
+ review: ['source', 'trails', 'contracts', 'graph', 'diagnostics'],
68
+ source: ['source', 'diagnostics'],
69
+ surfaces: ['trails', 'apps', 'surfaces', 'graph', 'diagnostics'],
70
+ } as const satisfies Record<string, readonly OutlineFeature[]>;
71
+
72
+ const outlineFeatureSchema = z.enum(outlineFeatureNames);
73
+ const outlineViewSchema = z.enum([
74
+ 'all',
75
+ 'contracts',
76
+ 'custom',
77
+ 'default',
78
+ 'review',
79
+ 'source',
80
+ 'surfaces',
81
+ ]);
82
+
83
+ export type OutlineFeature = z.infer<typeof outlineFeatureSchema>;
84
+ export type OutlineView = z.infer<typeof outlineViewSchema>;
85
+
86
+ export const outlineInputSchema = z
87
+ .object({
88
+ all: z
89
+ .boolean()
90
+ .default(false)
91
+ .describe('Show every outline feature family'),
92
+ contracts: z
93
+ .boolean()
94
+ .default(false)
95
+ .describe('Show trail contract and schema facts'),
96
+ features: z
97
+ .string()
98
+ .optional()
99
+ .describe('Comma-separated feature families to include'),
100
+ file: z.string().min(1).describe('Source file to outline'),
101
+ review: z
102
+ .boolean()
103
+ .default(false)
104
+ .describe('Show the source and contract facts most useful for review'),
105
+ rootDir: z.string().optional().describe('Workspace root directory'),
106
+ source: z
107
+ .boolean()
108
+ .default(false)
109
+ .describe('Show source-level imports, exports, and declarations'),
110
+ surfaces: z
111
+ .boolean()
112
+ .default(false)
113
+ .describe('Show surface and app membership facts'),
114
+ })
115
+ .strict();
116
+
117
+ export type OutlineInput = z.output<typeof outlineInputSchema>;
118
+
119
+ const sourceImportSchema = z.object({
120
+ names: z.array(z.string()).readonly(),
121
+ source: z.string(),
122
+ });
123
+
124
+ const sourceExportSchema = z.object({
125
+ line: z.number().int().positive(),
126
+ names: z.array(z.string()).readonly(),
127
+ source: z.string().optional(),
128
+ });
129
+
130
+ const sourceDeclarationSchema = z.object({
131
+ kind: z.enum([
132
+ 'class',
133
+ 'class-member',
134
+ 'const',
135
+ 'function',
136
+ 'interface',
137
+ 'type',
138
+ 'variable',
139
+ ]),
140
+ line: z.number().int().positive(),
141
+ name: z.string(),
142
+ });
143
+
144
+ const sourceAppSchema = z.object({
145
+ callee: z.string(),
146
+ line: z.number().int().positive(),
147
+ name: z.string(),
148
+ });
149
+
150
+ const sourceOutlineSchema = z.object({
151
+ declarations: z.array(sourceDeclarationSchema).readonly(),
152
+ exports: z.array(sourceExportSchema).readonly(),
153
+ imports: z.array(sourceImportSchema).readonly(),
154
+ lineCount: z.number().int().nonnegative(),
155
+ });
156
+
157
+ const trailOutlineSchema = z.object({
158
+ contracts: z
159
+ .object({
160
+ input: z.boolean(),
161
+ output: z.boolean(),
162
+ })
163
+ .optional(),
164
+ graph: z
165
+ .object({
166
+ exampleCount: z.number().int().nonnegative(),
167
+ intent: z.enum(['destroy', 'read', 'write']),
168
+ surfaces: z.array(z.string()).readonly(),
169
+ })
170
+ .optional(),
171
+ id: z.string(),
172
+ line: z.number().int().positive(),
173
+ });
174
+
175
+ const graphOutlineSchema = z.object({
176
+ matchedTrailIds: z.array(z.string()).readonly(),
177
+ source: z
178
+ .object({
179
+ artifactStatus: z.string(),
180
+ kind: z.string(),
181
+ path: z.string().optional(),
182
+ })
183
+ .nullable(),
184
+ });
185
+
186
+ const outlineDiagnosticSchema = z.object({
187
+ code: z.string(),
188
+ line: z.number().int().positive().optional(),
189
+ message: z.string(),
190
+ severity: z.enum(['error', 'info', 'warn']),
191
+ });
192
+
193
+ const outlineCountsSchema = z.object({
194
+ apps: z.number().int().nonnegative(),
195
+ declarations: z.number().int().nonnegative(),
196
+ diagnostics: z.number().int().nonnegative(),
197
+ graphMatches: z.number().int().nonnegative().optional(),
198
+ trails: z.number().int().nonnegative(),
199
+ });
200
+
201
+ export const outlineOutputSchema = z.object({
202
+ apps: z.array(sourceAppSchema).readonly().optional(),
203
+ counts: outlineCountsSchema,
204
+ diagnostics: z.array(outlineDiagnosticSchema).readonly().optional(),
205
+ features: z.object({
206
+ included: z.array(outlineFeatureSchema).readonly(),
207
+ omitted: z.array(outlineFeatureSchema).readonly(),
208
+ view: outlineViewSchema,
209
+ }),
210
+ file: z.string(),
211
+ graph: graphOutlineSchema.optional(),
212
+ rootDir: z.string(),
213
+ source: sourceOutlineSchema.optional(),
214
+ surfaces: z.array(z.string()).readonly().optional(),
215
+ trails: z.array(trailOutlineSchema).readonly().optional(),
216
+ });
217
+
218
+ export type OutlineOutput = z.output<typeof outlineOutputSchema>;
219
+
220
+ interface OutlineDiagnostic {
221
+ readonly code: string;
222
+ readonly line?: number | undefined;
223
+ readonly message: string;
224
+ readonly severity: 'error' | 'info' | 'warn';
225
+ }
226
+
227
+ interface SourceImport {
228
+ readonly names: readonly string[];
229
+ readonly source: string;
230
+ }
231
+
232
+ interface SourceExport {
233
+ readonly line: number;
234
+ readonly names: readonly string[];
235
+ readonly source?: string | undefined;
236
+ }
237
+
238
+ interface SourceDeclaration {
239
+ readonly kind:
240
+ | 'class'
241
+ | 'class-member'
242
+ | 'const'
243
+ | 'function'
244
+ | 'interface'
245
+ | 'type'
246
+ | 'variable';
247
+ readonly line: number;
248
+ readonly name: string;
249
+ }
250
+
251
+ interface SourceTrail {
252
+ readonly id: string;
253
+ readonly line: number;
254
+ }
255
+
256
+ interface SourceApp {
257
+ readonly callee: string;
258
+ readonly line: number;
259
+ readonly name: string;
260
+ }
261
+
262
+ interface ParsedSourceOutline {
263
+ readonly apps: readonly SourceApp[];
264
+ readonly declarations: readonly SourceDeclaration[];
265
+ readonly diagnostics: readonly OutlineDiagnostic[];
266
+ readonly exports: readonly SourceExport[];
267
+ readonly imports: readonly SourceImport[];
268
+ readonly lineCount: number;
269
+ readonly trails: readonly SourceTrail[];
270
+ }
271
+
272
+ const uniqueSorted = (values: readonly string[]): readonly string[] =>
273
+ [...new Set(values)].toSorted();
274
+
275
+ const lineFor = (sourceCode: string, node: AstNode): number =>
276
+ offsetToLineColumn(sourceCode, node.start).line;
277
+
278
+ const stringLiteralValue = (node: AstNode | undefined): string | undefined => {
279
+ if (!node) {
280
+ return undefined;
281
+ }
282
+ return getStringValue(node) ?? undefined;
283
+ };
284
+
285
+ const propertyName = (node: AstNode | undefined): string | undefined => {
286
+ if (!node) {
287
+ return undefined;
288
+ }
289
+ if (isIdentifier(node)) {
290
+ return identifierName(node) ?? undefined;
291
+ }
292
+ return stringLiteralValue(node);
293
+ };
294
+
295
+ const staticCalleeName = (node: AstNode | undefined): string | undefined => {
296
+ if (!node) {
297
+ return undefined;
298
+ }
299
+ if (isIdentifier(node)) {
300
+ return identifierName(node) ?? undefined;
301
+ }
302
+ if (isMemberExpression(node)) {
303
+ const receiver = identifierName(node.object);
304
+ const member = propertyName(node.property);
305
+ return receiver && member ? `${receiver}.${member}` : member;
306
+ }
307
+ return undefined;
308
+ };
309
+
310
+ const localImportName = (specifier: AstNode): string | undefined => {
311
+ if (!isImportSpecifier(specifier)) {
312
+ return undefined;
313
+ }
314
+ const { imported, local } = specifier;
315
+ if (specifier.type === 'ImportSpecifier') {
316
+ return (
317
+ identifierName(local) ??
318
+ identifierName(imported) ??
319
+ stringLiteralValue(imported) ??
320
+ undefined
321
+ );
322
+ }
323
+ return identifierName(local) ?? undefined;
324
+ };
325
+
326
+ const collectImports = (ast: AstNode): readonly SourceImport[] => {
327
+ const imports: SourceImport[] = [];
328
+ for (const node of isProgram(ast) ? (ast.body ?? []) : []) {
329
+ if (!isImportDeclaration(node)) {
330
+ continue;
331
+ }
332
+ const source = stringLiteralValue(node.source);
333
+ if (source === undefined) {
334
+ continue;
335
+ }
336
+ imports.push({
337
+ names: uniqueSorted(
338
+ (node.specifiers ?? []).flatMap((specifier) => {
339
+ const name = localImportName(specifier);
340
+ return name === undefined ? [] : [name];
341
+ })
342
+ ),
343
+ source,
344
+ });
345
+ }
346
+ return imports;
347
+ };
348
+
349
+ const declarationName = (node: AstNode | undefined): string | undefined => {
350
+ if (!isDeclarationWithId(node)) {
351
+ return undefined;
352
+ }
353
+ return identifierName(node.id) ?? undefined;
354
+ };
355
+
356
+ const variableKind = (declaration: AstNode): SourceDeclaration['kind'] =>
357
+ isVariableDeclaration(declaration) && declaration.kind === 'const'
358
+ ? 'const'
359
+ : 'variable';
360
+
361
+ const collectVariableDeclarations = (
362
+ node: AstNode,
363
+ kind: SourceDeclaration['kind'],
364
+ sourceCode: string
365
+ ): SourceDeclaration[] => {
366
+ if (!isVariableDeclaration(node)) {
367
+ return [];
368
+ }
369
+ return (node.declarations ?? []).flatMap((declarator) => {
370
+ if (!isVariableDeclarator(declarator)) {
371
+ return [];
372
+ }
373
+ const name = identifierName(declarator.id);
374
+ return name === null
375
+ ? []
376
+ : [{ kind, line: lineFor(sourceCode, declarator), name }];
377
+ });
378
+ };
379
+
380
+ const isTopLevelStatement = (context: AstParentContext): boolean => {
381
+ if (isProgram(context.parent)) {
382
+ return true;
383
+ }
384
+ if (
385
+ !isExportNamedDeclaration(context.parent) &&
386
+ !isExportDefaultDeclaration(context.parent)
387
+ ) {
388
+ return false;
389
+ }
390
+ return (
391
+ context.parent.parent === undefined || isProgram(context.parent.parent)
392
+ );
393
+ };
394
+
395
+ const collectDeclarations = (
396
+ ast: AstNode,
397
+ sourceCode: string
398
+ ): readonly SourceDeclaration[] => {
399
+ const declarations: SourceDeclaration[] = [];
400
+
401
+ walkWithParents(ast, (node, context) => {
402
+ if (isClassMember(node)) {
403
+ const name = propertyName(node.key);
404
+ if (name !== undefined) {
405
+ declarations.push({
406
+ kind: 'class-member',
407
+ line: lineFor(sourceCode, node),
408
+ name,
409
+ });
410
+ }
411
+ return;
412
+ }
413
+
414
+ if (!isTopLevelStatement(context)) {
415
+ return;
416
+ }
417
+
418
+ if (isDeclarationWithId(node) && node.type === 'FunctionDeclaration') {
419
+ const name = declarationName(node);
420
+ if (name !== undefined) {
421
+ declarations.push({
422
+ kind: 'function',
423
+ line: lineFor(sourceCode, node),
424
+ name,
425
+ });
426
+ }
427
+ return;
428
+ }
429
+
430
+ if (isDeclarationWithId(node) && node.type === 'ClassDeclaration') {
431
+ const name = declarationName(node);
432
+ if (name !== undefined) {
433
+ declarations.push({
434
+ kind: 'class',
435
+ line: lineFor(sourceCode, node),
436
+ name,
437
+ });
438
+ }
439
+ return;
440
+ }
441
+
442
+ if (
443
+ isDeclarationWithId(node) &&
444
+ (node.type === 'TSInterfaceDeclaration' ||
445
+ node.type === 'InterfaceDeclaration')
446
+ ) {
447
+ const name = declarationName(node);
448
+ if (name !== undefined) {
449
+ declarations.push({
450
+ kind: 'interface',
451
+ line: lineFor(sourceCode, node),
452
+ name,
453
+ });
454
+ }
455
+ return;
456
+ }
457
+
458
+ if (isDeclarationWithId(node) && node.type === 'TSTypeAliasDeclaration') {
459
+ const name = declarationName(node);
460
+ if (name !== undefined) {
461
+ declarations.push({
462
+ kind: 'type',
463
+ line: lineFor(sourceCode, node),
464
+ name,
465
+ });
466
+ }
467
+ return;
468
+ }
469
+
470
+ if (isVariableDeclaration(node)) {
471
+ declarations.push(
472
+ ...collectVariableDeclarations(node, variableKind(node), sourceCode)
473
+ );
474
+ }
475
+ });
476
+
477
+ return declarations.toSorted(
478
+ (a, b) => a.line - b.line || a.name.localeCompare(b.name)
479
+ );
480
+ };
481
+
482
+ const exportNamesFromDeclaration = (
483
+ declaration: AstNode
484
+ ): readonly string[] => {
485
+ if (isVariableDeclaration(declaration)) {
486
+ return collectVariableDeclarations(declaration, 'variable', '').map(
487
+ (entry) => entry.name
488
+ );
489
+ }
490
+ const name = declarationName(declaration);
491
+ return name === undefined ? [] : [name];
492
+ };
493
+
494
+ const collectExports = (
495
+ ast: AstNode,
496
+ sourceCode: string
497
+ ): readonly SourceExport[] => {
498
+ const exports: SourceExport[] = [];
499
+ for (const node of isProgram(ast) ? (ast.body ?? []) : []) {
500
+ if (!isExportDeclaration(node)) {
501
+ continue;
502
+ }
503
+ const exportSource = stringLiteralValue(node.source);
504
+ const names = uniqueSorted([
505
+ ...(node.declaration === undefined
506
+ ? []
507
+ : exportNamesFromDeclaration(node.declaration)),
508
+ ...(node.specifiers ?? []).flatMap((specifier) => {
509
+ if (!isExportSpecifier(specifier)) {
510
+ return [];
511
+ }
512
+ const { exported, local } = specifier;
513
+ return [propertyName(exported) ?? propertyName(local)].filter(
514
+ (name): name is string => name !== undefined
515
+ );
516
+ }),
517
+ ...(isExportDefaultDeclaration(node) ? ['default'] : []),
518
+ ...(isExportAllDeclaration(node) ? ['*'] : []),
519
+ ]);
520
+ exports.push({
521
+ line: lineFor(sourceCode, node),
522
+ names,
523
+ ...(exportSource === undefined ? {} : { source: exportSource }),
524
+ });
525
+ }
526
+ return exports;
527
+ };
528
+
529
+ const collectApps = (
530
+ ast: AstNode,
531
+ sourceCode: string
532
+ ): readonly SourceApp[] => {
533
+ const apps: SourceApp[] = [];
534
+ walkWithParents(ast, (node, context) => {
535
+ if (!isVariableDeclarator(node) || !isCallExpression(node.init)) {
536
+ return;
537
+ }
538
+ const callee = staticCalleeName(node.init.callee);
539
+ if (callee !== 'topo' && callee !== 'createTrailsApp') {
540
+ return;
541
+ }
542
+ const name = identifierName(node.id);
543
+ if (name === null) {
544
+ return;
545
+ }
546
+ const lineSource = context.parent ?? node;
547
+ apps.push({ callee, line: lineFor(sourceCode, lineSource), name });
548
+ });
549
+ return apps;
550
+ };
551
+
552
+ const parseSourceOutline = (
553
+ filePath: string,
554
+ sourceCode: string
555
+ ): ParsedSourceOutline | null => {
556
+ const parsed = parseWithDiagnostics(filePath, sourceCode);
557
+ if (parsed.ast === null) {
558
+ return null;
559
+ }
560
+
561
+ return {
562
+ apps: collectApps(parsed.ast, sourceCode),
563
+ declarations: collectDeclarations(parsed.ast, sourceCode),
564
+ diagnostics: parsed.diagnostics.map((diagnostic) => {
565
+ const [label] = diagnostic.labels;
566
+ return {
567
+ code: 'source.parse',
568
+ ...(label === undefined
569
+ ? {}
570
+ : { line: offsetToLineColumn(sourceCode, label.start).line }),
571
+ message: diagnostic.message,
572
+ severity:
573
+ diagnostic.severity.toLowerCase() === 'error' ? 'error' : 'warn',
574
+ };
575
+ }),
576
+ exports: collectExports(parsed.ast, sourceCode),
577
+ imports: collectImports(parsed.ast),
578
+ lineCount: sourceCode.length === 0 ? 0 : sourceCode.split('\n').length,
579
+ trails: findTrailDefinitions(parsed.ast).map((definition) => ({
580
+ id: definition.id,
581
+ line: offsetToLineColumn(sourceCode, definition.start).line,
582
+ })),
583
+ };
584
+ };
585
+
586
+ const selectedView = (
587
+ input: OutlineInput
588
+ ): Result<
589
+ { readonly features: readonly OutlineFeature[]; readonly view: OutlineView },
590
+ ValidationError
591
+ > => {
592
+ const selected = [
593
+ input.all ? 'all' : undefined,
594
+ input.contracts ? 'contracts' : undefined,
595
+ input.review ? 'review' : undefined,
596
+ input.source ? 'source' : undefined,
597
+ input.surfaces ? 'surfaces' : undefined,
598
+ input.features === undefined ? undefined : 'custom',
599
+ ].filter((value): value is OutlineView => value !== undefined);
600
+
601
+ if (selected.length > 1) {
602
+ return Result.err(
603
+ new ValidationError(
604
+ 'Choose only one outline view: --review, --source, --contracts, --surfaces, --all, or --features.'
605
+ )
606
+ );
607
+ }
608
+
609
+ const view = selected[0] ?? 'default';
610
+ if (view !== 'custom') {
611
+ return Result.ok({ features: outlineViewFeatures[view], view });
612
+ }
613
+
614
+ const values = (input.features ?? '')
615
+ .split(',')
616
+ .map((value) => value.trim())
617
+ .filter((value) => value.length > 0);
618
+ const parsed = z.array(outlineFeatureSchema).safeParse(values);
619
+ if (!parsed.success || values.length === 0) {
620
+ return Result.err(
621
+ new ValidationError(
622
+ `--features must be a comma-separated list drawn from: ${outlineFeatureNames.join(', ')}.`
623
+ )
624
+ );
625
+ }
626
+ return Result.ok({
627
+ features: uniqueSorted(parsed.data) as OutlineFeature[],
628
+ view,
629
+ });
630
+ };
631
+
632
+ const hasFeature = (
633
+ features: readonly OutlineFeature[],
634
+ feature: OutlineFeature
635
+ ): boolean => features.includes(feature);
636
+
637
+ const loadGraphSoft = async (
638
+ rootDir: string
639
+ ): Promise<{
640
+ readonly diagnostics: readonly OutlineDiagnostic[];
641
+ readonly graph: TopoGraph | null;
642
+ readonly load: WayfinderArtifactLoad | null;
643
+ }> => {
644
+ try {
645
+ const load = await loadWayfinderArtifacts({ rootDir });
646
+ if (load.topoGraph === null) {
647
+ return {
648
+ diagnostics: [
649
+ {
650
+ code: 'graph.missing',
651
+ message:
652
+ 'No saved Wayfinder TopoGraph artifact found. Source outline is still available. To add graph reconciliation, run `trails compile --module <app-module> --root-dir <workspace-root> --permit \'{"id":"operator","scopes":["topo:write"]}\'` with a permit authorized for topo:write.',
653
+ severity: 'warn',
654
+ },
655
+ ],
656
+ graph: null,
657
+ load,
658
+ };
659
+ }
660
+ return {
661
+ diagnostics:
662
+ load.artifactStatus.status === 'fresh'
663
+ ? []
664
+ : [
665
+ {
666
+ code: 'graph.artifact-status',
667
+ message: `Saved Wayfinder artifacts have ${load.artifactStatus.status} artifact status.`,
668
+ severity: 'warn',
669
+ },
670
+ ],
671
+ graph: load.topoGraph,
672
+ load,
673
+ };
674
+ } catch (error) {
675
+ return {
676
+ diagnostics: [
677
+ {
678
+ code: 'graph.load',
679
+ message:
680
+ error instanceof Error
681
+ ? error.message
682
+ : 'Unable to load Wayfinder artifacts.',
683
+ severity: 'warn',
684
+ },
685
+ ],
686
+ graph: null,
687
+ load: null,
688
+ };
689
+ }
690
+ };
691
+
692
+ const trailGraphFacts = (
693
+ graph: TopoGraph | null,
694
+ id: string
695
+ ):
696
+ | {
697
+ readonly exampleCount: number;
698
+ readonly intent: 'destroy' | 'read' | 'write';
699
+ readonly surfaces: readonly string[];
700
+ }
701
+ | undefined => {
702
+ const entry = graph?.entries.find(
703
+ (candidate): candidate is TopoGraphEntry =>
704
+ candidate.kind === 'trail' && candidate.id === id
705
+ );
706
+ if (entry === undefined) {
707
+ return undefined;
708
+ }
709
+ return {
710
+ exampleCount: entry.exampleCount,
711
+ intent: entry.intent ?? 'write',
712
+ surfaces: entry.surfaces,
713
+ };
714
+ };
715
+
716
+ const contractFacts = (
717
+ graph: TopoGraph | null,
718
+ id: string
719
+ ): { readonly input: boolean; readonly output: boolean } | undefined => {
720
+ const entry = graph?.entries.find(
721
+ (candidate) => candidate.kind === 'trail' && candidate.id === id
722
+ );
723
+ return entry === undefined
724
+ ? undefined
725
+ : { input: entry.input !== undefined, output: entry.output !== undefined };
726
+ };
727
+
728
+ const graphSourcePath = (rootDir: string): string =>
729
+ resolve(rootDir, 'trails.lock');
730
+
731
+ const graphOutline = (
732
+ rootDir: string,
733
+ graph: TopoGraph | null,
734
+ load: WayfinderArtifactLoad | null,
735
+ sourceTrails: readonly SourceTrail[]
736
+ ): z.output<typeof graphOutlineSchema> => ({
737
+ matchedTrailIds: sourceTrails
738
+ .map((sourceTrail) => sourceTrail.id)
739
+ .filter((id) =>
740
+ graph?.entries.some((entry) => entry.kind === 'trail' && entry.id === id)
741
+ )
742
+ .toSorted(),
743
+ source:
744
+ graph === null || load === null
745
+ ? null
746
+ : {
747
+ artifactStatus: load.artifactStatus.status,
748
+ kind: 'topoGraph',
749
+ path: graphSourcePath(rootDir),
750
+ },
751
+ });
752
+
753
+ const buildOutline = async (
754
+ input: OutlineInput,
755
+ cwd: string | undefined
756
+ ): Promise<Result<OutlineOutput, TrailsError>> => {
757
+ const view = selectedView(input);
758
+ if (view.isErr()) {
759
+ return view;
760
+ }
761
+
762
+ const rootDir = resolve(input.rootDir ?? cwd ?? process.cwd());
763
+ const safeFile = securePath(rootDir, input.file);
764
+ if (safeFile.isErr()) {
765
+ return safeFile;
766
+ }
767
+
768
+ let sourceCode: string;
769
+ try {
770
+ sourceCode = await readFile(safeFile.value, 'utf8');
771
+ } catch (error) {
772
+ const cause = error instanceof Error ? error : new Error(String(error));
773
+ return Result.err(
774
+ new NotFoundError(`Unable to read source file "${input.file}".`, {
775
+ cause,
776
+ context: { file: safeFile.value },
777
+ })
778
+ );
779
+ }
780
+
781
+ const parsed = parseSourceOutline(safeFile.value, sourceCode);
782
+ if (parsed === null) {
783
+ return Result.err(
784
+ new DerivationError(`Unable to parse source file "${input.file}".`, {
785
+ context: { file: safeFile.value },
786
+ })
787
+ );
788
+ }
789
+
790
+ const graphLoad = await loadGraphSoft(rootDir);
791
+ const diagnostics = [...parsed.diagnostics, ...graphLoad.diagnostics];
792
+ const { features } = view.value;
793
+ const omitted = outlineFeatureNames.filter(
794
+ (feature) => !features.includes(feature)
795
+ );
796
+ const relativeFile = relative(rootDir, safeFile.value) || input.file;
797
+ const trails = parsed.trails.map((sourceTrail) => ({
798
+ ...(hasFeature(features, 'contracts')
799
+ ? { contracts: contractFacts(graphLoad.graph, sourceTrail.id) }
800
+ : {}),
801
+ id: sourceTrail.id,
802
+ line: sourceTrail.line,
803
+ ...(hasFeature(features, 'graph')
804
+ ? { graph: trailGraphFacts(graphLoad.graph, sourceTrail.id) }
805
+ : {}),
806
+ }));
807
+ const surfaces = uniqueSorted(
808
+ parsed.trails.flatMap(
809
+ (sourceTrail) =>
810
+ trailGraphFacts(graphLoad.graph, sourceTrail.id)?.surfaces ?? []
811
+ )
812
+ );
813
+ const graphMatches = parsed.trails.filter((sourceTrail) =>
814
+ graphLoad.graph?.entries.some(
815
+ (entry) => entry.kind === 'trail' && entry.id === sourceTrail.id
816
+ )
817
+ ).length;
818
+
819
+ return Result.ok({
820
+ ...(hasFeature(features, 'apps') ? { apps: parsed.apps } : {}),
821
+ counts: {
822
+ apps: parsed.apps.length,
823
+ declarations: parsed.declarations.length,
824
+ diagnostics: diagnostics.length,
825
+ ...(hasFeature(features, 'graph') ? { graphMatches } : {}),
826
+ trails: parsed.trails.length,
827
+ },
828
+ ...(hasFeature(features, 'diagnostics') ? { diagnostics } : {}),
829
+ features: {
830
+ included: features,
831
+ omitted,
832
+ view: view.value.view,
833
+ },
834
+ file: relativeFile,
835
+ ...(hasFeature(features, 'graph')
836
+ ? {
837
+ graph: graphOutline(
838
+ rootDir,
839
+ graphLoad.graph,
840
+ graphLoad.load,
841
+ parsed.trails
842
+ ),
843
+ }
844
+ : {}),
845
+ rootDir,
846
+ ...(hasFeature(features, 'source')
847
+ ? {
848
+ source: {
849
+ declarations: parsed.declarations,
850
+ exports: parsed.exports,
851
+ imports: parsed.imports,
852
+ lineCount: parsed.lineCount,
853
+ },
854
+ }
855
+ : {}),
856
+ ...(hasFeature(features, 'surfaces') ? { surfaces } : {}),
857
+ ...(hasFeature(features, 'trails') ? { trails } : {}),
858
+ });
859
+ };
860
+
861
+ export const wayfindOutlineTrail = trail('wayfind.outline', {
862
+ args: ['file'],
863
+ description:
864
+ 'Outline one source file and connect source structure to saved Trails graph facts',
865
+ examples: [
866
+ {
867
+ input: { file: 'apps/trails/src/app.ts', rootDir: '.' },
868
+ name: 'Outline a Trails source file',
869
+ },
870
+ ],
871
+ implementation: async (input, ctx) => buildOutline(input, ctx.cwd),
872
+ input: outlineInputSchema,
873
+ intent: 'read',
874
+ output: outlineOutputSchema,
875
+ visibility: 'internal',
876
+ });