@ontrails/trails 0.2.0

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 (121) hide show
  1. package/CHANGELOG.md +1906 -0
  2. package/README.md +48 -0
  3. package/bin/trails.ts +3 -0
  4. package/package.json +57 -0
  5. package/src/app.ts +167 -0
  6. package/src/clack.ts +111 -0
  7. package/src/cli.ts +308 -0
  8. package/src/completions.ts +431 -0
  9. package/src/lifecycle-source-io.ts +33 -0
  10. package/src/load-app-mirror.ts +202 -0
  11. package/src/local-state-io.ts +129 -0
  12. package/src/mcp-app.ts +42 -0
  13. package/src/mcp-options.ts +92 -0
  14. package/src/mcp.ts +8 -0
  15. package/src/project-writes.ts +377 -0
  16. package/src/regrade/audit.ts +571 -0
  17. package/src/regrade/config.ts +152 -0
  18. package/src/regrade/history.ts +636 -0
  19. package/src/regrade/lifecycle.ts +76 -0
  20. package/src/regrade/live-api-preserve.ts +123 -0
  21. package/src/regrade/plan-artifact.ts +515 -0
  22. package/src/regrade/plan-derivation.ts +301 -0
  23. package/src/regrade/prepared-run.ts +259 -0
  24. package/src/regrade/receipt-history.ts +446 -0
  25. package/src/regrade/source-transaction.ts +185 -0
  26. package/src/release/bindings.ts +58 -0
  27. package/src/release/changeset-packages.ts +99 -0
  28. package/src/release/check.ts +1191 -0
  29. package/src/release/cli-bundle.ts +575 -0
  30. package/src/release/config.ts +73 -0
  31. package/src/release/contract-facts.ts +425 -0
  32. package/src/release/homebrew.ts +221 -0
  33. package/src/release/index.ts +180 -0
  34. package/src/release/lock-roundtrip-smoke.ts +255 -0
  35. package/src/release/lock-roundtrip-workspace.ts +107 -0
  36. package/src/release/native-bun-publish.ts +964 -0
  37. package/src/release/native-bun-registry.ts +848 -0
  38. package/src/release/notes-cli.ts +171 -0
  39. package/src/release/notes.ts +390 -0
  40. package/src/release/pack-coherence.ts +455 -0
  41. package/src/release/package-route-facts.ts +146 -0
  42. package/src/release/packed-artifacts-smoke.ts +236 -0
  43. package/src/release/policy.ts +1780 -0
  44. package/src/release/semver.ts +104 -0
  45. package/src/release/smoke.ts +56 -0
  46. package/src/release/stable-version-release.ts +80 -0
  47. package/src/release/wayfinder-dogfood-smoke.ts +762 -0
  48. package/src/release/zero-line-transition.ts +68 -0
  49. package/src/retired-topo-command.ts +36 -0
  50. package/src/run-adapter-check.ts +76 -0
  51. package/src/run-argv.ts +133 -0
  52. package/src/run-collision.ts +126 -0
  53. package/src/run-completions-install.ts +179 -0
  54. package/src/run-example.ts +149 -0
  55. package/src/run-examples.ts +148 -0
  56. package/src/run-quiet.ts +75 -0
  57. package/src/run-regrade-progress.ts +47 -0
  58. package/src/run-release-check.ts +74 -0
  59. package/src/run-schema.ts +74 -0
  60. package/src/run-trace.ts +273 -0
  61. package/src/run-warden.ts +39 -0
  62. package/src/run-watch-project.ts +52 -0
  63. package/src/run-watch.ts +381 -0
  64. package/src/run-wayfind-outline.ts +170 -0
  65. package/src/scaffold-version-sync.ts +183 -0
  66. package/src/scaffold-versions.generated.ts +12 -0
  67. package/src/trails/adapter-check.ts +244 -0
  68. package/src/trails/add-surface.ts +816 -0
  69. package/src/trails/add-trail.ts +141 -0
  70. package/src/trails/add-verify.ts +252 -0
  71. package/src/trails/compile.ts +118 -0
  72. package/src/trails/completions-complete.ts +236 -0
  73. package/src/trails/completions.ts +47 -0
  74. package/src/trails/config-explain.ts +43 -0
  75. package/src/trails/create-adapter.ts +785 -0
  76. package/src/trails/create-scaffold.ts +1215 -0
  77. package/src/trails/create-versions.ts +62 -0
  78. package/src/trails/create.ts +652 -0
  79. package/src/trails/deprecate.ts +59 -0
  80. package/src/trails/dev-clean.ts +80 -0
  81. package/src/trails/dev-reset.ts +48 -0
  82. package/src/trails/dev-stats.ts +71 -0
  83. package/src/trails/dev-support.ts +360 -0
  84. package/src/trails/doctor.ts +77 -0
  85. package/src/trails/draft-promote.ts +949 -0
  86. package/src/trails/guide.ts +106 -0
  87. package/src/trails/load-app.ts +1145 -0
  88. package/src/trails/operator-context.ts +66 -0
  89. package/src/trails/project-context-output.ts +304 -0
  90. package/src/trails/project-context.ts +613 -0
  91. package/src/trails/project.ts +65 -0
  92. package/src/trails/regrade.ts +4951 -0
  93. package/src/trails/release-check.ts +113 -0
  94. package/src/trails/release-smoke.ts +49 -0
  95. package/src/trails/revise.ts +53 -0
  96. package/src/trails/root-dir.ts +21 -0
  97. package/src/trails/run-example.ts +592 -0
  98. package/src/trails/run-examples.ts +149 -0
  99. package/src/trails/run.ts +496 -0
  100. package/src/trails/scaffold-json.ts +60 -0
  101. package/src/trails/scaffold-topo-identity.ts +479 -0
  102. package/src/trails/survey.ts +990 -0
  103. package/src/trails/topo-activation.ts +14 -0
  104. package/src/trails/topo-constants.ts +2 -0
  105. package/src/trails/topo-history.ts +47 -0
  106. package/src/trails/topo-output-schemas.ts +259 -0
  107. package/src/trails/topo-pin.ts +38 -0
  108. package/src/trails/topo-read-support.ts +368 -0
  109. package/src/trails/topo-reports.ts +809 -0
  110. package/src/trails/topo-store-support.ts +323 -0
  111. package/src/trails/topo-support.ts +247 -0
  112. package/src/trails/topo-unpin.ts +61 -0
  113. package/src/trails/topo.ts +92 -0
  114. package/src/trails/validate.ts +348 -0
  115. package/src/trails/version-lifecycle-support.ts +936 -0
  116. package/src/trails/warden-guide.ts +134 -0
  117. package/src/trails/warden.ts +598 -0
  118. package/src/trails/wayfind-diff.ts +716 -0
  119. package/src/trails/wayfind-outline.ts +876 -0
  120. package/src/trails/wayfind.ts +1319 -0
  121. package/src/versions.ts +31 -0
@@ -0,0 +1,381 @@
1
+ /**
2
+ * CLI-surface bridge for the `--watch` flag (`trails run --watch`).
3
+ *
4
+ * `--watch` is a local-development ergonomics affordance for `trails run`.
5
+ * After the first invocation completes, the CLI installs a filesystem
6
+ * watcher as a cheap event source. On each debounced event, the watcher
7
+ * re-derives the watched trail's resolved-contract hash from its TopoGraph
8
+ * entry and invokes the supplied `onRerun` callback only when that hash
9
+ * changes. The loop runs until the user sends `SIGINT`.
10
+ *
11
+ * Design notes:
12
+ *
13
+ * - **Scope.** Watching is intentionally narrow. Filesystem events only wake
14
+ * the loop; the rerun decision is the watched trail's TopoGraph entry.
15
+ * Comments, whitespace, and unrelated sibling trail changes wake the loop
16
+ * but do not rerun unless the resolved contract changes.
17
+ * - **Debounce.** Editor saves often produce multiple `fs.watch` events
18
+ * per logical change (write tmp, rename, touch mtime). The debounce
19
+ * coalesces these into a single rerun and dampens AFS / iCloud sync
20
+ * bursts.
21
+ * - **No external deps.** Uses `node:fs.watch` (re-exported by Bun) so
22
+ * we avoid pulling in `chokidar` or similar.
23
+ */
24
+
25
+ import { once } from 'node:events';
26
+ import { watch as nodeWatch } from 'node:fs';
27
+ import type { FSWatcher } from 'node:fs';
28
+ import { dirname, extname } from 'node:path';
29
+
30
+ import type { TopoGraphEntry } from '@ontrails/topography';
31
+
32
+ export { readRunTrailId } from './run-argv.js';
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Constants
36
+ // ---------------------------------------------------------------------------
37
+
38
+ /**
39
+ * Debounce window (ms) for coalescing rapid filesystem events into a single
40
+ * rerun. Sized small enough to feel instantaneous but large enough to
41
+ * absorb editor save bursts and sync-driven duplicate notifications.
42
+ */
43
+ export const WATCH_DEBOUNCE_MS = 100;
44
+
45
+ /**
46
+ * Warmup window (ms) after the watcher is created during which incoming
47
+ * events are ignored.
48
+ *
49
+ * On macOS, `fs.watch` (FSEvents) routinely emits a phantom `rename`
50
+ * event for files that already existed in the watched directory shortly
51
+ * after the watcher is installed. Ignoring events within this short
52
+ * warmup prevents a spurious rerun on the first invocation without
53
+ * meaningfully delaying real edits.
54
+ *
55
+ * Applied uniformly across platforms — the cost is negligible (no human
56
+ * saves within ~150ms of starting `trails run --watch`), and a
57
+ * platform-specific branch isn't worth the complexity.
58
+ */
59
+ export const WATCH_WARMUP_MS = 150;
60
+
61
+ /** Extensions considered relevant to a trail rerun. */
62
+ const WATCHED_EXTENSIONS: ReadonlySet<string> = new Set([
63
+ '.ts',
64
+ '.tsx',
65
+ '.js',
66
+ '.mjs',
67
+ '.cjs',
68
+ ]);
69
+
70
+ const ANSI_CLEAR_SCREEN = '\u001B[2J\u001B[H';
71
+
72
+ const WATCH_SCHEMA_INVALID_MESSAGE =
73
+ '[watch] schema invalid; skipping rerun until valid\n';
74
+ const WATCH_TRAIL_REMOVED_MESSAGE = '[watch] trail removed; awaiting return\n';
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Argv detection
78
+ // ---------------------------------------------------------------------------
79
+
80
+ /**
81
+ * Detect whether `--watch` appears in argv.
82
+ *
83
+ * Pre-parsed argv detection lets the CLI install the watcher loop before
84
+ * `surface()` parses argv. The flag is also wired through the build
85
+ * pipeline as a meta flag, so trail input is unaffected.
86
+ */
87
+ export const argvHasWatchFlag = (argv: readonly string[]): boolean =>
88
+ argv.includes('--watch');
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // Helpers
92
+ // ---------------------------------------------------------------------------
93
+
94
+ const formatError = (error: unknown): string => {
95
+ if (error instanceof Error) {
96
+ return error.message;
97
+ }
98
+ return String(error);
99
+ };
100
+
101
+ const isRelevantFilename = (filename: string | null): boolean => {
102
+ if (filename === null || filename.length === 0) {
103
+ return false;
104
+ }
105
+ return WATCHED_EXTENSIONS.has(extname(filename));
106
+ };
107
+
108
+ const canonicalize = (value: unknown): unknown => {
109
+ if (Array.isArray(value)) {
110
+ return value.map(canonicalize);
111
+ }
112
+ if (value !== null && typeof value === 'object') {
113
+ const sorted: Record<string, unknown> = {};
114
+ for (const key of Object.keys(value).toSorted()) {
115
+ sorted[key] = canonicalize((value as Record<string, unknown>)[key]);
116
+ }
117
+ return sorted;
118
+ }
119
+ return value;
120
+ };
121
+
122
+ export const hashTopoGraphEntry = (entry: TopoGraphEntry): string => {
123
+ const hasher = new Bun.CryptoHasher('sha256');
124
+ hasher.update(JSON.stringify(canonicalize(entry)));
125
+ return hasher.digest('hex');
126
+ };
127
+
128
+ export type ReadTopoGraphEntryHash = () =>
129
+ | Promise<string | null>
130
+ | string
131
+ | null;
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // Watcher
135
+ // ---------------------------------------------------------------------------
136
+
137
+ /** Options for {@link createTrailWatcher}. */
138
+ export interface CreateTrailWatcherOptions {
139
+ /**
140
+ * Absolute path to the resolved trail's source file. The watcher targets
141
+ * the directory containing this file (non-recursive) and filters events
142
+ * to relevant extensions only.
143
+ */
144
+ readonly sourcePath: string;
145
+ /**
146
+ * Invoked once per debounced change burst. Errors thrown by the callback
147
+ * are caught and reported to stderr so a misbehaving handler does not
148
+ * tear down the watcher loop.
149
+ */
150
+ readonly onRerun: () => void | Promise<void>;
151
+ /**
152
+ * Derive the watched trail's current resolved-contract hash. Return `null`
153
+ * when the watched trail is temporarily absent. Throw when the current
154
+ * source state cannot produce a valid TopoGraph.
155
+ */
156
+ readonly readTopoGraphEntryHash: ReadTopoGraphEntryHash;
157
+ /**
158
+ * Last known good resolved-contract hash captured after the initial run.
159
+ * When omitted, the next valid changed hash becomes the first rerun signal.
160
+ */
161
+ readonly initialTopoGraphEntryHash?: string | null | undefined;
162
+ /**
163
+ * Override for the debounce window. Primarily a test seam; production
164
+ * callers should rely on {@link WATCH_DEBOUNCE_MS}.
165
+ */
166
+ readonly debounceMs?: number | undefined;
167
+ /**
168
+ * Override for the warmup window. Primarily a test seam; production
169
+ * callers should rely on {@link WATCH_WARMUP_MS}.
170
+ */
171
+ readonly warmupMs?: number | undefined;
172
+ }
173
+
174
+ /** Handle returned by {@link createTrailWatcher}. */
175
+ export interface TrailWatcher {
176
+ /**
177
+ * Stop the watcher and clear any pending debounce timer. Idempotent —
178
+ * subsequent calls are no-ops.
179
+ */
180
+ readonly close: () => void;
181
+ }
182
+
183
+ /**
184
+ * Create a filesystem watcher that triggers `onRerun` whenever a relevant
185
+ * filesystem event changes the watched trail's resolved contract.
186
+ *
187
+ * The watcher targets the directory of `sourcePath` (non-recursive). Events
188
+ * are filtered to TypeScript/JavaScript file extensions and coalesced through
189
+ * a short debounce window. Each debounced event re-reads the watched trail's
190
+ * TopoGraph entry hash; only a hash change reruns the trail.
191
+ *
192
+ * @remarks Reruns are not serialized. If a save lands while a previous
193
+ * rerun is still awaiting `onRerun`, the new debounce window can fire
194
+ * concurrently. In practice the {@link WATCH_DEBOUNCE_MS} window plus
195
+ * realistic save cadences make this uncommon, and each `surface()` call
196
+ * from the loop is independent. Callers that share mutable surface
197
+ * state (e.g. a global trace sink) must scope it per invocation —
198
+ * `runSurfaceOnce` in `apps/trails/src/cli.ts` does this for `--trace`.
199
+ */
200
+ export const createTrailWatcher = (
201
+ options: CreateTrailWatcherOptions
202
+ ): TrailWatcher => {
203
+ const debounceMs = options.debounceMs ?? WATCH_DEBOUNCE_MS;
204
+ const warmupMs = options.warmupMs ?? WATCH_WARMUP_MS;
205
+ const watchDir = dirname(options.sourcePath);
206
+ const startedAt = Date.now();
207
+
208
+ let closed = false;
209
+ let currentTopoGraphEntryHash = options.initialTopoGraphEntryHash ?? null;
210
+ let invalidTopoGraphWarned = false;
211
+ let trailRemovedWarned = false;
212
+ let pending: ReturnType<typeof setTimeout> | undefined;
213
+ let watcher: FSWatcher | undefined;
214
+
215
+ const readNextTopoGraphEntryHash = async (): Promise<
216
+ string | null | undefined
217
+ > => {
218
+ try {
219
+ const nextHash = await options.readTopoGraphEntryHash();
220
+ invalidTopoGraphWarned = false;
221
+ if (nextHash !== null) {
222
+ trailRemovedWarned = false;
223
+ } else if (!trailRemovedWarned) {
224
+ process.stderr.write(WATCH_TRAIL_REMOVED_MESSAGE);
225
+ trailRemovedWarned = true;
226
+ }
227
+ return nextHash;
228
+ } catch {
229
+ if (!invalidTopoGraphWarned) {
230
+ process.stderr.write(WATCH_SCHEMA_INVALID_MESSAGE);
231
+ invalidTopoGraphWarned = true;
232
+ }
233
+ return undefined;
234
+ }
235
+ };
236
+
237
+ const fireRerun = async (): Promise<void> => {
238
+ pending = undefined;
239
+ if (closed) {
240
+ return;
241
+ }
242
+ const nextHash = await readNextTopoGraphEntryHash();
243
+ if (closed) {
244
+ return;
245
+ }
246
+ if (nextHash === undefined || nextHash === null) {
247
+ return;
248
+ }
249
+ if (nextHash === currentTopoGraphEntryHash) {
250
+ return;
251
+ }
252
+ currentTopoGraphEntryHash = nextHash;
253
+ try {
254
+ await options.onRerun();
255
+ } catch (error: unknown) {
256
+ process.stderr.write(`watch: rerun failed: ${formatError(error)}\n`);
257
+ }
258
+ };
259
+
260
+ const scheduleRerun = (): void => {
261
+ if (closed) {
262
+ return;
263
+ }
264
+ if (pending !== undefined) {
265
+ clearTimeout(pending);
266
+ }
267
+ pending = setTimeout(() => {
268
+ void fireRerun();
269
+ }, debounceMs);
270
+ };
271
+
272
+ watcher = nodeWatch(
273
+ watchDir,
274
+ { persistent: true, recursive: false },
275
+ (_event, filename) => {
276
+ if (Date.now() - startedAt < warmupMs) {
277
+ // Suppress FSEvents replay of pre-existing files on macOS.
278
+ return;
279
+ }
280
+ if (!isRelevantFilename(filename)) {
281
+ return;
282
+ }
283
+ scheduleRerun();
284
+ }
285
+ );
286
+
287
+ watcher.on('error', (error: Error) => {
288
+ process.stderr.write(`watch: watcher error: ${error.message}\n`);
289
+ });
290
+
291
+ return {
292
+ close: () => {
293
+ if (closed) {
294
+ return;
295
+ }
296
+ closed = true;
297
+ if (pending !== undefined) {
298
+ clearTimeout(pending);
299
+ pending = undefined;
300
+ }
301
+ if (watcher !== undefined) {
302
+ watcher.close();
303
+ watcher = undefined;
304
+ }
305
+ },
306
+ };
307
+ };
308
+
309
+ // ---------------------------------------------------------------------------
310
+ // Watch loop
311
+ // ---------------------------------------------------------------------------
312
+
313
+ /** Options for {@link runWatchLoop}. */
314
+ export interface RunWatchLoopOptions {
315
+ /** Absolute path to the resolved trail's source file. */
316
+ readonly sourcePath: string;
317
+ /** Invoked once per debounced change burst (and once initially). */
318
+ readonly run: () => Promise<void>;
319
+ /** Derive the watched trail's current resolved-contract hash. */
320
+ readonly readTopoGraphEntryHash: ReadTopoGraphEntryHash;
321
+ /**
322
+ * Override for the debounce window. Primarily a test seam.
323
+ */
324
+ readonly debounceMs?: number | undefined;
325
+ /**
326
+ * Whether to clear the terminal between reruns. Defaults to `true` for
327
+ * the standard interactive experience; tests pass `false` to keep
328
+ * captured output legible.
329
+ */
330
+ readonly clearScreen?: boolean | undefined;
331
+ }
332
+
333
+ /**
334
+ * Run the trail once, then install a watcher and re-run on changes until
335
+ * `SIGINT` is received. Returns the exit code (always `0` on a clean
336
+ * SIGINT shutdown).
337
+ *
338
+ * @remarks This is the high-level entry point used by the CLI binary.
339
+ * Tests should target {@link createTrailWatcher} directly rather than
340
+ * spawning a subprocess to drive this loop.
341
+ */
342
+ export const runWatchLoop = async (
343
+ options: RunWatchLoopOptions
344
+ ): Promise<number> => {
345
+ const clearScreen = options.clearScreen ?? true;
346
+
347
+ const performRun = async (): Promise<void> => {
348
+ if (clearScreen) {
349
+ process.stdout.write(ANSI_CLEAR_SCREEN);
350
+ }
351
+ try {
352
+ await options.run();
353
+ } catch (error: unknown) {
354
+ process.stderr.write(`watch: run failed: ${formatError(error)}\n`);
355
+ }
356
+ };
357
+
358
+ await performRun();
359
+
360
+ let initialTopoGraphEntryHash: string | null = null;
361
+ try {
362
+ initialTopoGraphEntryHash = await options.readTopoGraphEntryHash();
363
+ } catch {
364
+ process.stderr.write(WATCH_SCHEMA_INVALID_MESSAGE);
365
+ }
366
+
367
+ const watcher = createTrailWatcher({
368
+ debounceMs: options.debounceMs,
369
+ initialTopoGraphEntryHash,
370
+ onRerun: performRun,
371
+ readTopoGraphEntryHash: options.readTopoGraphEntryHash,
372
+ sourcePath: options.sourcePath,
373
+ });
374
+
375
+ // `once(emitter, 'event')` returns a Promise that resolves when the
376
+ // event fires. Cleaner than `new Promise(resolve => emitter.on(...))`
377
+ // and aligns with `eslint-plugin-promise/avoid-new`.
378
+ await once(process, 'SIGINT');
379
+ watcher.close();
380
+ return 0;
381
+ };
@@ -0,0 +1,170 @@
1
+ import { deriveOutputMode, output } from '@ontrails/cli';
2
+ import type { ActionResultContext } from '@ontrails/cli';
3
+
4
+ import { outlineOutputSchema } from './trails/wayfind-outline.js';
5
+ import type {
6
+ OutlineFeature,
7
+ OutlineOutput,
8
+ } from './trails/wayfind-outline.js';
9
+
10
+ const includesFeature = (
11
+ outline: OutlineOutput,
12
+ feature: OutlineFeature
13
+ ): boolean => outline.features.included.includes(feature);
14
+
15
+ const formatLine = (
16
+ line: number,
17
+ kind: string,
18
+ name: string,
19
+ suffix = ''
20
+ ): string => `${line.toString().padStart(4, ' ')}: ${kind} ${name}${suffix}`;
21
+
22
+ const appendGraphCount = (lines: string[], outline: OutlineOutput): void => {
23
+ if (
24
+ includesFeature(outline, 'graph') &&
25
+ outline.counts.graphMatches !== undefined
26
+ ) {
27
+ lines.push(` graph matches: ${outline.counts.graphMatches.toString()}`);
28
+ }
29
+ };
30
+
31
+ const appendDiagnosticCount = (
32
+ lines: string[],
33
+ outline: OutlineOutput
34
+ ): void => {
35
+ if (
36
+ includesFeature(outline, 'diagnostics') &&
37
+ outline.counts.diagnostics > 0
38
+ ) {
39
+ lines.push(` diagnostics: ${outline.counts.diagnostics.toString()}`);
40
+ }
41
+ };
42
+
43
+ const appendSourceDeclarations = (
44
+ lines: string[],
45
+ outline: OutlineOutput
46
+ ): void => {
47
+ if (!includesFeature(outline, 'source')) {
48
+ return;
49
+ }
50
+ const declarations = outline.source?.declarations ?? [];
51
+ if (declarations.length === 0) {
52
+ return;
53
+ }
54
+ lines.push('');
55
+ for (const declaration of declarations.slice(0, 40)) {
56
+ lines.push(
57
+ formatLine(declaration.line, declaration.kind, declaration.name)
58
+ );
59
+ }
60
+ };
61
+
62
+ type TrailOutline = NonNullable<OutlineOutput['trails']>[number];
63
+
64
+ const contractFactLabel = (
65
+ contracts: TrailOutline['contracts']
66
+ ): string | undefined => {
67
+ if (contracts === undefined) {
68
+ return undefined;
69
+ }
70
+ if (contracts.input && contracts.output) {
71
+ return 'input+output';
72
+ }
73
+ if (contracts.input) {
74
+ return 'input';
75
+ }
76
+ if (contracts.output) {
77
+ return 'output';
78
+ }
79
+ return 'no schemas';
80
+ };
81
+
82
+ const exampleCountLabel = (count: number): string =>
83
+ `${count.toString()} ${count === 1 ? 'example' : 'examples'}`;
84
+
85
+ const trailFactSuffix = (trail: TrailOutline): string => {
86
+ const facts = [
87
+ trail.graph?.intent,
88
+ contractFactLabel(trail.contracts),
89
+ trail.graph === undefined
90
+ ? undefined
91
+ : exampleCountLabel(trail.graph.exampleCount),
92
+ ].filter((fact): fact is string => fact !== undefined);
93
+
94
+ return facts.length === 0 ? '' : ` (${facts.join(', ')})`;
95
+ };
96
+
97
+ const appendTrails = (lines: string[], outline: OutlineOutput): void => {
98
+ if (!includesFeature(outline, 'trails')) {
99
+ return;
100
+ }
101
+ const trails = outline.trails ?? [];
102
+ if (trails.length === 0) {
103
+ return;
104
+ }
105
+ lines.push('');
106
+ for (const trail of trails) {
107
+ lines.push(
108
+ formatLine(trail.line, 'trail', trail.id, trailFactSuffix(trail))
109
+ );
110
+ }
111
+ };
112
+
113
+ const appendApps = (lines: string[], outline: OutlineOutput): void => {
114
+ if (!includesFeature(outline, 'apps')) {
115
+ return;
116
+ }
117
+ const apps = outline.apps ?? [];
118
+ if (apps.length === 0) {
119
+ return;
120
+ }
121
+ lines.push('');
122
+ for (const app of apps) {
123
+ lines.push(formatLine(app.line, 'app', app.name, ` (${app.callee})`));
124
+ }
125
+ };
126
+
127
+ const appendDiagnostics = (lines: string[], outline: OutlineOutput): void => {
128
+ if (!includesFeature(outline, 'diagnostics')) {
129
+ return;
130
+ }
131
+ for (const diagnostic of outline.diagnostics ?? []) {
132
+ lines.push(
133
+ ` ${diagnostic.severity}: ${diagnostic.code}: ${diagnostic.message}`
134
+ );
135
+ }
136
+ };
137
+
138
+ export const formatWayfindOutlineText = (outline: OutlineOutput): string => {
139
+ const lines = [
140
+ outline.file,
141
+ ` trails: ${outline.counts.trails.toString()}`,
142
+ ` apps: ${outline.counts.apps.toString()}`,
143
+ ` declarations: ${outline.counts.declarations.toString()}`,
144
+ ];
145
+
146
+ appendGraphCount(lines, outline);
147
+ appendDiagnosticCount(lines, outline);
148
+ appendSourceDeclarations(lines, outline);
149
+ appendTrails(lines, outline);
150
+ appendApps(lines, outline);
151
+ appendDiagnostics(lines, outline);
152
+
153
+ return lines.join('\n');
154
+ };
155
+
156
+ export const tryWayfindOutlineOutput = (ctx: ActionResultContext): boolean => {
157
+ if (ctx.trail.id !== 'wayfind.outline' || ctx.result.isErr()) {
158
+ return false;
159
+ }
160
+ const { mode } = deriveOutputMode(ctx.flags, ctx.topoName);
161
+ if (mode !== 'text') {
162
+ return false;
163
+ }
164
+ const parsed = outlineOutputSchema.safeParse(ctx.result.value);
165
+ if (!parsed.success) {
166
+ return false;
167
+ }
168
+ output(formatWayfindOutlineText(parsed.data), mode);
169
+ return true;
170
+ };