@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,990 @@
1
+ /**
2
+ * `survey` trail -- Full topo introspection.
3
+ *
4
+ * Lists trails, looks up trails/resources/signals, and diffs against previous
5
+ * versions.
6
+ */
7
+
8
+ import { basename, extname, join } from 'node:path';
9
+
10
+ import type { Topo } from '@ontrails/core';
11
+ import {
12
+ deriveSafePath,
13
+ NotFoundError,
14
+ Result,
15
+ trail,
16
+ ValidationError,
17
+ } from '@ontrails/core';
18
+ import type {
19
+ DiffEntry,
20
+ DiffResult,
21
+ TopoGraph,
22
+ TopoGraphOverlayRegistration,
23
+ } from '@ontrails/topography';
24
+ import {
25
+ createTopoStore,
26
+ deriveTopoGraphDiff,
27
+ deriveTopoGraph,
28
+ resolveTopoGraphVersionReference,
29
+ readTopoGraph,
30
+ } from '@ontrails/topography';
31
+ import { z } from 'zod';
32
+
33
+ import { withFreshAppLease, withOperatorRootDir } from './operator-context.js';
34
+ import { assertConfiguredAppBinding } from './project-context.js';
35
+ import {
36
+ deriveCurrentTopoBrief,
37
+ deriveCurrentTopoList,
38
+ deriveCurrentTopoMatches,
39
+ deriveCurrentTrailDetail,
40
+ deriveCurrentResourceDetail,
41
+ deriveCurrentSignalDetail,
42
+ readSurfaceLayerNamesFromContext,
43
+ } from './topo-read-support.js';
44
+ import {
45
+ activationOverviewOutput,
46
+ resourceDetailOutput,
47
+ shippedSurfaceInventoryOutput,
48
+ signalDetailOutput,
49
+ trailDetailOutput,
50
+ } from './topo-output-schemas.js';
51
+ import { createCurrentAppExampleInput } from './topo-support.js';
52
+ import {
53
+ briefReportSchema,
54
+ deriveShippedSurfaceInventory,
55
+ } from './topo-reports.js';
56
+ import type { SurfaceLayerNames } from './topo-reports.js';
57
+
58
+ export {
59
+ briefReportSchema,
60
+ deriveBriefReport,
61
+ deriveResourceDetail,
62
+ deriveShippedSurfaceInventory,
63
+ deriveSignalDetail,
64
+ deriveSurveyList,
65
+ deriveTrailDetail,
66
+ } from './topo-reports.js';
67
+ export type {
68
+ BriefReport,
69
+ ShippedSurfaceInventoryReport,
70
+ ShippedSurfaceDerived,
71
+ SignalDetailReport,
72
+ SurfaceLayerNames,
73
+ SurveyListReport,
74
+ TrailDetailReport,
75
+ } from './topo-reports.js';
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Survey diff helpers
79
+ // ---------------------------------------------------------------------------
80
+
81
+ interface SurveyDiffReport {
82
+ readonly against: string;
83
+ readonly breaking: readonly DiffEntry[];
84
+ readonly hasBreaking: boolean;
85
+ readonly info: readonly DiffEntry[];
86
+ readonly mode: 'diff';
87
+ readonly warnings: readonly DiffEntry[];
88
+ }
89
+
90
+ interface DiffInput {
91
+ readonly against?: string | undefined;
92
+ readonly breakingOnly?: boolean | undefined;
93
+ readonly breaks?: boolean | undefined;
94
+ readonly forces?: boolean | undefined;
95
+ readonly module?: string | undefined;
96
+ readonly rootDir?: string | undefined;
97
+ readonly target?: string | undefined;
98
+ }
99
+
100
+ interface ParsedDiffTarget {
101
+ readonly id: string;
102
+ readonly versions?: ReadonlySet<number> | undefined;
103
+ }
104
+
105
+ const formatDiff = (diff: DiffResult, against: string): SurveyDiffReport => ({
106
+ against,
107
+ breaking: diff.breaking,
108
+ hasBreaking: diff.hasBreaking,
109
+ info: diff.info,
110
+ mode: 'diff',
111
+ warnings: diff.warnings,
112
+ });
113
+
114
+ const partitionDiffEntries = (entries: readonly DiffEntry[]): DiffResult => {
115
+ const sorted = [...entries].toSorted((left, right) =>
116
+ left.id.localeCompare(right.id)
117
+ );
118
+ const breaking = sorted.filter((entry) => entry.severity === 'breaking');
119
+ const warnings = sorted.filter((entry) => entry.severity === 'warning');
120
+ const info = sorted.filter((entry) => entry.severity === 'info');
121
+
122
+ return {
123
+ breaking,
124
+ entries: sorted,
125
+ hasBreaking: breaking.length > 0,
126
+ info,
127
+ warnings,
128
+ };
129
+ };
130
+
131
+ const parseVersionRange = (
132
+ reference: string
133
+ ): ReadonlySet<number> | undefined => {
134
+ const match = /^(\d+)\.\.(\d+)$/.exec(reference);
135
+ if (match === null) {
136
+ return undefined;
137
+ }
138
+ const start = Number(match[1]);
139
+ const end = Number(match[2]);
140
+ if (start < 1 || end < start) {
141
+ throw new ValidationError(
142
+ `Diff version range must use ascending positive versions: ${reference}`
143
+ );
144
+ }
145
+
146
+ return new Set(
147
+ Array.from({ length: end - start + 1 }, (_value, index) => start + index)
148
+ );
149
+ };
150
+
151
+ const findDiffTargetEntry = (
152
+ previous: TopoGraph,
153
+ current: TopoGraph,
154
+ id: string
155
+ ) =>
156
+ current.entries.find((entry) => entry.id === id) ??
157
+ previous.entries.find((entry) => entry.id === id);
158
+
159
+ const parseDiffTarget = (
160
+ previous: TopoGraph,
161
+ current: TopoGraph,
162
+ target: string | undefined
163
+ ): Result<ParsedDiffTarget | undefined, Error> => {
164
+ if (target === undefined || target.length === 0) {
165
+ return Result.ok();
166
+ }
167
+
168
+ const separator = target.lastIndexOf('@');
169
+ const id = separator === -1 ? target : target.slice(0, separator);
170
+ const reference =
171
+ separator === -1 ? undefined : target.slice(separator + 1).trim();
172
+ if (id.length === 0 || reference === '') {
173
+ return Result.err(
174
+ new ValidationError('Diff target must use trail.id or trail.id@version')
175
+ );
176
+ }
177
+
178
+ const entry = findDiffTargetEntry(previous, current, id);
179
+ if (entry === undefined) {
180
+ return Result.err(new NotFoundError(`Trail not found for diff: ${id}`));
181
+ }
182
+
183
+ if (reference === undefined) {
184
+ return Result.ok({ id });
185
+ }
186
+
187
+ try {
188
+ const range = parseVersionRange(reference);
189
+ if (range !== undefined) {
190
+ return Result.ok({ id, versions: range });
191
+ }
192
+
193
+ return Result.ok({
194
+ id,
195
+ versions: new Set([
196
+ resolveTopoGraphVersionReference(entry, reference).version,
197
+ ]),
198
+ });
199
+ } catch (error: unknown) {
200
+ return Result.err(
201
+ error instanceof Error ? error : new Error(String(error))
202
+ );
203
+ }
204
+ };
205
+
206
+ const detailVersions = (detail: string): readonly number[] => {
207
+ const match = /^(?:Live version|Version) (\d+)\b/.exec(detail);
208
+ if (match !== null) {
209
+ return [Number(match[1])];
210
+ }
211
+
212
+ const supportMatch = /^Supported versions (?:added|removed): (.+)$/.exec(
213
+ detail
214
+ );
215
+ if (supportMatch === null) {
216
+ return [];
217
+ }
218
+
219
+ return (supportMatch[1] ?? '')
220
+ .split(',')
221
+ .map((part) => Number(part.trim()))
222
+ .filter((version) => Number.isInteger(version) && version > 0);
223
+ };
224
+
225
+ type DiffSeverity = DiffEntry['severity'];
226
+
227
+ const severityRank: Record<DiffSeverity, number> = {
228
+ breaking: 2,
229
+ info: 0,
230
+ warning: 1,
231
+ };
232
+
233
+ const higherSeverity = (
234
+ left: DiffSeverity,
235
+ right: DiffSeverity
236
+ ): DiffSeverity => (severityRank[right] > severityRank[left] ? right : left);
237
+
238
+ const versionStatus = (detail: string): string | undefined =>
239
+ /^Version \d+ (?:added|removed) \(([^)]+)\)$/.exec(detail)?.[1];
240
+
241
+ const visibleDetailSeverity = (detail: string): DiffSeverity => {
242
+ if (detail.startsWith('Force event ')) {
243
+ return 'warning';
244
+ }
245
+ if (detail.startsWith('Supported versions removed: ')) {
246
+ return 'breaking';
247
+ }
248
+ if (detail.startsWith('Supported versions added: ')) {
249
+ return 'info';
250
+ }
251
+ if (
252
+ /^Live version \d+ (?:added without examples|example coverage removed)$/.test(
253
+ detail
254
+ )
255
+ ) {
256
+ return 'warning';
257
+ }
258
+ if (detail.startsWith('Live version ') && detail.includes(' examples: ')) {
259
+ return 'info';
260
+ }
261
+ if (/^Version \d+ status changed: .+ -> archived$/.test(detail)) {
262
+ return 'warning';
263
+ }
264
+ if (detail.startsWith('Version ') && detail.includes(' status changed: ')) {
265
+ return 'info';
266
+ }
267
+
268
+ const status = versionStatus(detail);
269
+ if (detail.startsWith('Version ') && detail.includes(' removed (')) {
270
+ return status === 'archived' ? 'warning' : 'breaking';
271
+ }
272
+ if (detail.startsWith('Version ') && detail.includes(' added (')) {
273
+ return status === 'archived' ? 'info' : 'warning';
274
+ }
275
+ if (
276
+ /^Version \d+ (?:kind changed:|Required (?:input|entity) field ".+" added|(?:Input|Output|Entity) field ".+" (?:removed|type changed:|changed from optional to required))/.test(
277
+ detail
278
+ )
279
+ ) {
280
+ return 'breaking';
281
+ }
282
+ if (
283
+ /^Version \d+ (?:marker changed:|Optional (?:input|entity) field ".+" added|Output field ".+" added)/.test(
284
+ detail
285
+ )
286
+ ) {
287
+ return 'info';
288
+ }
289
+
290
+ return 'info';
291
+ };
292
+
293
+ const visibleDetailsSeverity = (details: readonly string[]): DiffSeverity => {
294
+ let severity: DiffSeverity = 'info';
295
+ for (const detail of details) {
296
+ severity = higherSeverity(severity, visibleDetailSeverity(detail));
297
+ }
298
+ return severity;
299
+ };
300
+
301
+ const detailsChanged = (
302
+ previous: readonly string[],
303
+ next: readonly string[]
304
+ ): boolean =>
305
+ previous.length !== next.length ||
306
+ previous.some((detail, index) => detail !== next[index]);
307
+
308
+ const filterDetails = (
309
+ details: readonly string[],
310
+ target: ParsedDiffTarget | undefined,
311
+ forcesOnly: boolean
312
+ ): readonly string[] => {
313
+ const visible = forcesOnly
314
+ ? details.filter((detail) => detail.startsWith('Force event '))
315
+ : [...details];
316
+ if (target?.versions === undefined || forcesOnly) {
317
+ return visible;
318
+ }
319
+
320
+ return visible.filter((detail) => {
321
+ const versions = detailVersions(detail);
322
+ return versions.some((version) => target.versions?.has(version));
323
+ });
324
+ };
325
+
326
+ const filterDiff = (
327
+ diff: DiffResult,
328
+ target: ParsedDiffTarget | undefined,
329
+ options: Pick<DiffInput, 'breakingOnly' | 'breaks' | 'forces'>
330
+ ): DiffResult => {
331
+ const entries = diff.entries.flatMap((entry): DiffEntry[] => {
332
+ if (target !== undefined && entry.id !== target.id) {
333
+ return [];
334
+ }
335
+ const details = filterDetails(
336
+ entry.details,
337
+ target,
338
+ options.forces === true
339
+ );
340
+ if (details.length === 0) {
341
+ return [];
342
+ }
343
+ return [
344
+ {
345
+ ...entry,
346
+ details,
347
+ severity: detailsChanged(entry.details, details)
348
+ ? visibleDetailsSeverity(details)
349
+ : entry.severity,
350
+ },
351
+ ];
352
+ });
353
+
354
+ const partitioned = partitionDiffEntries(entries);
355
+ return options.breakingOnly === true || options.breaks === true
356
+ ? partitionDiffEntries(partitioned.breaking)
357
+ : partitioned;
358
+ };
359
+
360
+ const createDiffExampleInput = (): {
361
+ readonly against: string;
362
+ readonly module: string;
363
+ readonly rootDir: string;
364
+ } => ({
365
+ ...createCurrentAppExampleInput(),
366
+ against: 'saved',
367
+ });
368
+
369
+ const isNotFound = (error: unknown): boolean =>
370
+ typeof error === 'object' &&
371
+ error !== null &&
372
+ (error as NodeJS.ErrnoException).code === 'ENOENT';
373
+
374
+ const readTopoGraphFile = async (
375
+ filePath: string
376
+ ): Promise<TopoGraph | null> => {
377
+ try {
378
+ return (await Bun.file(filePath).json()) as TopoGraph;
379
+ } catch (error: unknown) {
380
+ if (isNotFound(error)) {
381
+ return null;
382
+ }
383
+ throw error;
384
+ }
385
+ };
386
+
387
+ const readStoredTopoGraph = (
388
+ rootDir: string,
389
+ against: string
390
+ ): TopoGraph | undefined => {
391
+ try {
392
+ const store = createTopoStore({ rootDir });
393
+ const stored =
394
+ store.exports.get({ pin: against }) ??
395
+ store.exports.get({ snapshotId: against });
396
+ return stored === undefined
397
+ ? undefined
398
+ : (JSON.parse(stored.topoGraphJson) as TopoGraph);
399
+ } catch (error: unknown) {
400
+ if (error instanceof NotFoundError) {
401
+ return undefined;
402
+ }
403
+ throw error;
404
+ }
405
+ };
406
+
407
+ const readPathTopoGraph = async (
408
+ rootDir: string,
409
+ against: string
410
+ ): Promise<Result<TopoGraph | null, Error>> => {
411
+ const safePath = deriveSafePath(rootDir, against);
412
+ if (safePath.isErr()) {
413
+ return safePath;
414
+ }
415
+
416
+ return Result.ok(
417
+ basename(safePath.value) === 'topo.lock' ||
418
+ extname(safePath.value) === '.json'
419
+ ? await readTopoGraphFile(safePath.value)
420
+ : await readTopoGraph({ dir: safePath.value })
421
+ );
422
+ };
423
+
424
+ const describeAgainstPathTarget = (against: string): string =>
425
+ basename(against) === 'topo.lock' || extname(against) === '.json'
426
+ ? 'workspace-relative TopoGraph file'
427
+ : 'workspace-relative directory containing trails.lock or topo.lock';
428
+
429
+ const topoGraphNotFound = (against: string): NotFoundError =>
430
+ new NotFoundError(
431
+ `No TopoGraph found for: ${against}. Tried ${describeAgainstPathTarget(
432
+ against
433
+ )}, then topo-store pin and snapshot references.`
434
+ );
435
+
436
+ const readAgainstTopoGraph = async (
437
+ rootDir: string,
438
+ against?: string | undefined
439
+ ): Promise<Result<{ against: string; map: TopoGraph }, Error>> => {
440
+ if (against === undefined || against === 'saved') {
441
+ const map =
442
+ (await readTopoGraph({ dir: rootDir })) ??
443
+ (await readTopoGraph({ dir: join(rootDir, '.trails') }));
444
+ return map === null
445
+ ? Result.err(
446
+ new NotFoundError(
447
+ 'No saved TopoGraph found. Run `trails compile` first.'
448
+ )
449
+ )
450
+ : Result.ok({ against: 'saved', map });
451
+ }
452
+
453
+ // Treat explicit filesystem targets as the most local user intent; stored
454
+ // pins and snapshot ids are fallback references when no path exists.
455
+ const pathMap = await readPathTopoGraph(rootDir, against);
456
+ if (pathMap.isErr()) {
457
+ return pathMap;
458
+ }
459
+ if (pathMap.value !== null) {
460
+ return Result.ok({ against, map: pathMap.value });
461
+ }
462
+
463
+ const storedMap = readStoredTopoGraph(rootDir, against);
464
+ if (storedMap !== undefined) {
465
+ return Result.ok({ against, map: storedMap });
466
+ }
467
+
468
+ return Result.err(topoGraphNotFound(against));
469
+ };
470
+
471
+ const buildSurveyDiff = async (
472
+ app: Topo,
473
+ rootDir: string,
474
+ input: DiffInput
475
+ ): Promise<Result<SurveyDiffReport, Error>> => {
476
+ const currentMap = deriveTopoGraph(app);
477
+ const previous = await readAgainstTopoGraph(rootDir, input.against);
478
+ if (previous.isErr()) {
479
+ return previous;
480
+ }
481
+
482
+ const target = parseDiffTarget(previous.value.map, currentMap, input.target);
483
+ if (target.isErr()) {
484
+ return target;
485
+ }
486
+
487
+ const diff = filterDiff(
488
+ deriveTopoGraphDiff(previous.value.map, currentMap),
489
+ target.value,
490
+ input
491
+ );
492
+ return Result.ok(formatDiff(diff, previous.value.against));
493
+ };
494
+
495
+ const buildSurveyLookup = (
496
+ app: Topo,
497
+ entityId: string,
498
+ rootDir: string,
499
+ overlays: readonly TopoGraphOverlayRegistration[] | undefined,
500
+ surfaceLayerNames?: Partial<SurfaceLayerNames> | undefined
501
+ ): Result<object, Error> => {
502
+ const matches = deriveCurrentTopoMatches(app, entityId, {
503
+ overlays,
504
+ rootDir,
505
+ surfaceLayerNames,
506
+ });
507
+ return Result.ok({ matches });
508
+ };
509
+
510
+ const buildSurveyTrailDetail = (
511
+ app: Topo,
512
+ id: string,
513
+ rootDir: string,
514
+ overlays: readonly TopoGraphOverlayRegistration[] | undefined,
515
+ surfaceLayerNames?: Partial<SurfaceLayerNames> | undefined
516
+ ): Result<object, Error> => {
517
+ const detail = deriveCurrentTrailDetail(app, id, {
518
+ overlays,
519
+ rootDir,
520
+ surfaceLayerNames,
521
+ });
522
+ return detail === undefined
523
+ ? Result.err(new NotFoundError(`Trail not found: ${id}`))
524
+ : Result.ok(detail);
525
+ };
526
+
527
+ const buildSurveyResourceDetail = (
528
+ app: Topo,
529
+ id: string,
530
+ rootDir: string
531
+ ): Result<object, Error> => {
532
+ const detail = deriveCurrentResourceDetail(app, id, { rootDir });
533
+ return detail === undefined
534
+ ? Result.err(new NotFoundError(`Resource not found: ${id}`))
535
+ : Result.ok(detail);
536
+ };
537
+
538
+ const buildSurveySignalDetail = (
539
+ app: Topo,
540
+ id: string,
541
+ rootDir: string
542
+ ): Result<object, Error> => {
543
+ const detail = deriveCurrentSignalDetail(app, id, { rootDir });
544
+ return detail === undefined
545
+ ? Result.err(new NotFoundError(`Signal not found: ${id}`))
546
+ : Result.ok(detail);
547
+ };
548
+
549
+ const buildSurveySurfaceInventory = (app: Topo): Result<object, Error> =>
550
+ Result.ok(deriveShippedSurfaceInventory(app));
551
+
552
+ interface SurveyInput {
553
+ id?: string | undefined;
554
+ module?: string | undefined;
555
+ rootDir?: string | undefined;
556
+ }
557
+
558
+ type SurveyMode = 'lookup' | 'overview';
559
+
560
+ type SurveyEnvelope = { readonly mode: SurveyMode } & Record<string, unknown>;
561
+
562
+ /** Determine which survey mode was requested, falling back to 'overview'. */
563
+ const deriveSurveyMode = (input: SurveyInput): SurveyMode =>
564
+ input.id === undefined || input.id === '' ? 'overview' : 'lookup';
565
+
566
+ type SurveyHandler = (
567
+ app: Topo,
568
+ input: SurveyInput,
569
+ rootDir: string,
570
+ overlays: readonly TopoGraphOverlayRegistration[] | undefined,
571
+ surfaceLayerNames?: Partial<SurfaceLayerNames> | undefined
572
+ ) => Result<object, Error> | Promise<Result<object, Error>>;
573
+
574
+ /** Handlers keyed by survey mode. */
575
+ const surveyHandlers: Record<SurveyMode, SurveyHandler> = {
576
+ lookup: (app, input, rootDir, overlays, surfaceLayerNames) =>
577
+ input.id === undefined || input.id === ''
578
+ ? Result.err(new ValidationError('Survey lookup requires an id'))
579
+ : buildSurveyLookup(app, input.id, rootDir, overlays, surfaceLayerNames),
580
+ overview: (app, _input, rootDir) =>
581
+ Result.ok(deriveCurrentTopoList(app, { rootDir })),
582
+ };
583
+
584
+ const envelopeSurveyValue = (
585
+ mode: SurveyMode,
586
+ value: object
587
+ ): SurveyEnvelope => ({ ...value, mode });
588
+
589
+ /** Dispatch to the appropriate survey sub-command based on input flags. */
590
+ const dispatchSurvey = async (
591
+ app: Topo,
592
+ input: SurveyInput,
593
+ rootDir: string,
594
+ overlays: readonly TopoGraphOverlayRegistration[] | undefined,
595
+ surfaceLayerNames?: Partial<SurfaceLayerNames> | undefined
596
+ ): Promise<Result<SurveyEnvelope, Error>> => {
597
+ const mode = deriveSurveyMode(input);
598
+ const handler = surveyHandlers[mode];
599
+ const result = await handler(
600
+ app,
601
+ input,
602
+ rootDir,
603
+ overlays,
604
+ surfaceLayerNames
605
+ );
606
+ if (result.isErr()) {
607
+ return result;
608
+ }
609
+ return Result.ok(envelopeSurveyValue(mode, result.value));
610
+ };
611
+
612
+ const detailInputSchema = z.object({
613
+ id: z.string().describe('Trail, resource, or signal ID'),
614
+ module: z.string().optional().describe('Path to the app module'),
615
+ rootDir: z.string().optional().describe('Workspace root directory'),
616
+ });
617
+
618
+ const withFreshSurveyApp = async <T>(
619
+ input: {
620
+ readonly configuredApp?:
621
+ | {
622
+ readonly id: string;
623
+ readonly modulePath: string;
624
+ readonly projectRoot: string;
625
+ }
626
+ | undefined;
627
+ readonly module?: string | undefined;
628
+ },
629
+ rootDir: string,
630
+ consume: (
631
+ app: Topo,
632
+ overlays: readonly TopoGraphOverlayRegistration[] | undefined
633
+ ) => Promise<Result<T, Error>> | Result<T, Error>
634
+ ): Promise<Result<T, Error>> =>
635
+ withFreshAppLease(input.module, rootDir, async (lease) => {
636
+ if (input.configuredApp !== undefined) {
637
+ const binding = assertConfiguredAppBinding(
638
+ {
639
+ app: {
640
+ id: input.configuredApp.id,
641
+ modulePath: input.configuredApp.modulePath,
642
+ },
643
+ projectRoot: input.configuredApp.projectRoot,
644
+ },
645
+ lease.app.name
646
+ );
647
+ if (binding.isErr()) {
648
+ return binding;
649
+ }
650
+ }
651
+ return await consume(lease.app, lease.overlays);
652
+ });
653
+
654
+ const withResolvedSurveyApp = async <T>(
655
+ input: {
656
+ readonly configuredApp?:
657
+ | {
658
+ readonly id: string;
659
+ readonly modulePath: string;
660
+ readonly projectRoot: string;
661
+ }
662
+ | undefined;
663
+ readonly module?: string | undefined;
664
+ readonly rootDir?: string | undefined;
665
+ },
666
+ cwd: string | undefined,
667
+ consume: (
668
+ app: Topo,
669
+ rootDir: string,
670
+ overlays: readonly TopoGraphOverlayRegistration[] | undefined
671
+ ) => Promise<Result<T, Error>> | Result<T, Error>
672
+ ): Promise<Result<T, Error>> =>
673
+ withOperatorRootDir(input, { cwd }, (rootDir) =>
674
+ withFreshSurveyApp(input, rootDir, (app, overlays) =>
675
+ consume(app, rootDir, overlays)
676
+ )
677
+ );
678
+
679
+ const moduleInputSchema = z.object({
680
+ module: z.string().optional().describe('Path to the app module'),
681
+ rootDir: z.string().optional().describe('Workspace root directory'),
682
+ });
683
+
684
+ const surveyComposeInputSchema = z.object({
685
+ configuredApp: z
686
+ .object({
687
+ id: z.string(),
688
+ modulePath: z.string(),
689
+ projectRoot: z.string(),
690
+ })
691
+ .optional(),
692
+ });
693
+
694
+ const diffEntryOutput = z.object({
695
+ change: z.enum(['added', 'removed', 'modified']),
696
+ details: z.array(z.string()).readonly(),
697
+ id: z.string(),
698
+ kind: z.enum(['entity', 'trail', 'signal', 'resource', 'trailhead']),
699
+ severity: z.enum(['info', 'warning', 'breaking']),
700
+ });
701
+
702
+ const diffOutput = z.object({
703
+ against: z.string(),
704
+ breaking: z.array(diffEntryOutput),
705
+ hasBreaking: z.boolean(),
706
+ info: z.array(diffEntryOutput),
707
+ mode: z.literal('diff'),
708
+ warnings: z.array(diffEntryOutput),
709
+ });
710
+
711
+ const diffInputSchema = z.object({
712
+ against: z
713
+ .string()
714
+ .min(1)
715
+ .optional()
716
+ .describe(
717
+ 'Saved TopoGraph target: "saved", a workspace path (topo.lock, .json file, or directory with topo.lock), then a pin/snapshot id'
718
+ ),
719
+ breakingOnly: z
720
+ .boolean()
721
+ .default(false)
722
+ .describe('Legacy alias for --breaks; only show breaking changes'),
723
+ breaks: z.boolean().default(false).describe('Only show breaking changes'),
724
+ forces: z
725
+ .boolean()
726
+ .default(false)
727
+ .describe('Only show graph force audit events'),
728
+ module: z.string().optional().describe('Path to the app module'),
729
+ rootDir: z.string().optional().describe('Workspace root directory'),
730
+ target: z
731
+ .string()
732
+ .min(1)
733
+ .optional()
734
+ .describe('Trail or trail version target, such as user.create@1..2'),
735
+ });
736
+
737
+ const surveyMatchOutput = z.discriminatedUnion('kind', [
738
+ z.object({
739
+ detail: trailDetailOutput,
740
+ kind: z.literal('trail'),
741
+ }),
742
+ z.object({
743
+ detail: resourceDetailOutput,
744
+ kind: z.literal('resource'),
745
+ }),
746
+ z.object({
747
+ detail: signalDetailOutput,
748
+ kind: z.literal('signal'),
749
+ }),
750
+ ]);
751
+
752
+ // ---------------------------------------------------------------------------
753
+ // Trail definition
754
+ // ---------------------------------------------------------------------------
755
+
756
+ export const surveyTrail = trail('survey', {
757
+ args: ['id'],
758
+ composeInput: surveyComposeInputSchema,
759
+ description: 'Full topo introspection',
760
+ examples: [
761
+ {
762
+ description: 'Show all registered trails, resources, and signals',
763
+ input: createCurrentAppExampleInput(),
764
+ name: 'Overview',
765
+ },
766
+ {
767
+ description: 'Find every trail, resource, or signal with a matching ID',
768
+ input: { ...createCurrentAppExampleInput(), id: 'survey' },
769
+ name: 'Lookup by ID',
770
+ },
771
+ ],
772
+ implementation: async (input, ctx) =>
773
+ withResolvedSurveyApp(input, ctx.cwd, (app, rootDir, overlays) =>
774
+ dispatchSurvey(
775
+ app,
776
+ input,
777
+ rootDir,
778
+ overlays,
779
+ readSurfaceLayerNamesFromContext(ctx)
780
+ )
781
+ ),
782
+ input: z.object({
783
+ id: z
784
+ .string()
785
+ .optional()
786
+ .describe('Trail, resource, or signal ID to look up'),
787
+ module: z.string().optional().describe('Path to the app module'),
788
+ rootDir: z.string().optional().describe('Workspace root directory'),
789
+ }),
790
+ intent: 'read',
791
+ output: z.discriminatedUnion('mode', [
792
+ z.object({
793
+ activation: activationOverviewOutput,
794
+ count: z.number(),
795
+ entries: z.array(
796
+ z.object({
797
+ activatedBy: z.array(z.string()).readonly(),
798
+ activates: z.array(z.string()).readonly(),
799
+ examples: z.number(),
800
+ id: z.string(),
801
+ kind: z.string(),
802
+ safety: z.string(),
803
+ })
804
+ ),
805
+ mode: z.literal('overview'),
806
+ resourceCount: z.number(),
807
+ resources: z.array(
808
+ z.object({
809
+ description: z.string().nullable(),
810
+ health: z.enum(['available', 'none']),
811
+ id: z.string(),
812
+ kind: z.literal('resource'),
813
+ lifetime: z.literal('singleton'),
814
+ usedBy: z.array(z.string()),
815
+ })
816
+ ),
817
+ signalCount: z.number(),
818
+ signals: z.array(
819
+ z.object({
820
+ consumers: z.array(z.string()).readonly(),
821
+ description: z.string().nullable(),
822
+ examples: z.number(),
823
+ from: z.array(z.string()).readonly(),
824
+ id: z.string(),
825
+ kind: z.literal('signal'),
826
+ payloadSchema: z.boolean(),
827
+ producers: z.array(z.string()).readonly(),
828
+ })
829
+ ),
830
+ }),
831
+ z.object({
832
+ matches: z.array(surveyMatchOutput),
833
+ mode: z.literal('lookup'),
834
+ }),
835
+ ]),
836
+ });
837
+
838
+ export const surveyBriefTrail = trail('survey.brief', {
839
+ description: 'Summarize topo capabilities',
840
+ examples: [
841
+ {
842
+ description: 'Show counts and feature flags',
843
+ input: createCurrentAppExampleInput(),
844
+ name: 'Brief capability report',
845
+ },
846
+ ],
847
+ implementation: async (input, ctx) =>
848
+ withResolvedSurveyApp(input, ctx.cwd, (app, rootDir) =>
849
+ Result.ok(deriveCurrentTopoBrief(app, { rootDir }))
850
+ ),
851
+ input: moduleInputSchema,
852
+ intent: 'read',
853
+ output: briefReportSchema,
854
+ });
855
+
856
+ export const surveySurfacesTrail = trail('survey.surfaces', {
857
+ description: 'Inventory shipped surface derived facts',
858
+ examples: [
859
+ {
860
+ description: 'Show CLI, MCP, and HTTP derived facts for public trails',
861
+ input: createCurrentAppExampleInput(),
862
+ name: 'Shipped surface inventory',
863
+ },
864
+ ],
865
+ implementation: async (input, ctx) =>
866
+ withResolvedSurveyApp(input, ctx.cwd, (app) =>
867
+ buildSurveySurfaceInventory(app)
868
+ ),
869
+ input: moduleInputSchema,
870
+ intent: 'read',
871
+ output: shippedSurfaceInventoryOutput,
872
+ });
873
+
874
+ export const surveyDiffTrail = trail('survey.diff', {
875
+ args: ['target'],
876
+ description: 'Diff the current topo against a saved TopoGraph',
877
+ examples: [
878
+ {
879
+ description: 'Compare current topo to a saved TopoGraph directory',
880
+ input: createDiffExampleInput(),
881
+ name: 'Diff against baseline',
882
+ },
883
+ {
884
+ description: 'Show only breaking contract drift',
885
+ input: { ...createDiffExampleInput(), breaks: true },
886
+ name: 'Breaking changes',
887
+ },
888
+ {
889
+ description: 'Show graph-only force audit events',
890
+ input: { ...createDiffExampleInput(), forces: true },
891
+ name: 'Force audit events',
892
+ },
893
+ {
894
+ description: 'Reject an empty saved map target',
895
+ error: 'ValidationError',
896
+ input: { against: '' },
897
+ name: 'Reject empty diff target',
898
+ },
899
+ {
900
+ description: 'Reject an empty target before filtering breaking drift',
901
+ error: 'ValidationError',
902
+ input: {
903
+ against: '',
904
+ breakingOnly: true,
905
+ },
906
+ name: 'Reject empty breaking-only target',
907
+ },
908
+ ],
909
+ implementation: async (input, ctx) =>
910
+ withResolvedSurveyApp(input, ctx.cwd, (app, rootDir) =>
911
+ buildSurveyDiff(app, rootDir, input)
912
+ ),
913
+ input: diffInputSchema,
914
+ intent: 'read',
915
+ output: diffOutput,
916
+ });
917
+
918
+ export const surveyTrailDetailTrail = trail('survey.trail', {
919
+ args: ['id'],
920
+ description: 'Inspect one trail by ID',
921
+ examples: [
922
+ {
923
+ description: 'Show trail contract detail',
924
+ input: {
925
+ ...createCurrentAppExampleInput(),
926
+ id: 'survey',
927
+ },
928
+ name: 'Trail detail',
929
+ },
930
+ ],
931
+ implementation: async (input, ctx) =>
932
+ withResolvedSurveyApp(input, ctx.cwd, (app, rootDir, overlays) =>
933
+ buildSurveyTrailDetail(
934
+ app,
935
+ input.id,
936
+ rootDir,
937
+ overlays,
938
+ readSurfaceLayerNamesFromContext(ctx)
939
+ )
940
+ ),
941
+ input: detailInputSchema,
942
+ intent: 'read',
943
+ output: trailDetailOutput,
944
+ });
945
+
946
+ export const surveyResourceTrail = trail('survey.resource', {
947
+ args: ['id'],
948
+ description: 'Inspect one resource by ID',
949
+ examples: [
950
+ {
951
+ description: 'Show resource usage detail',
952
+ error: 'NotFoundError',
953
+ input: {
954
+ ...createCurrentAppExampleInput(),
955
+ id: 'db.main',
956
+ },
957
+ name: 'Resource detail',
958
+ },
959
+ ],
960
+ implementation: async (input, ctx) =>
961
+ withResolvedSurveyApp(input, ctx.cwd, (app, rootDir) =>
962
+ buildSurveyResourceDetail(app, input.id, rootDir)
963
+ ),
964
+ input: detailInputSchema,
965
+ intent: 'read',
966
+ output: resourceDetailOutput,
967
+ });
968
+
969
+ export const surveySignalTrail = trail('survey.signal', {
970
+ args: ['id'],
971
+ description: 'Inspect one signal by ID',
972
+ examples: [
973
+ {
974
+ description: 'Show signal producer and consumer detail',
975
+ error: 'NotFoundError',
976
+ input: {
977
+ ...createCurrentAppExampleInput(),
978
+ id: 'hello.greeted',
979
+ },
980
+ name: 'Signal detail',
981
+ },
982
+ ],
983
+ implementation: async (input, ctx) =>
984
+ withResolvedSurveyApp(input, ctx.cwd, (app, rootDir) =>
985
+ buildSurveySignalDetail(app, input.id, rootDir)
986
+ ),
987
+ input: detailInputSchema,
988
+ intent: 'read',
989
+ output: signalDetailOutput,
990
+ });