@ontrails/trails 1.0.0-beta.13 → 1.0.0-beta.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/.turbo/turbo-lint.log +1 -1
  2. package/CHANGELOG.md +29 -0
  3. package/__tests__/examples.test.ts +39 -0
  4. package/dist/src/app.d.ts.map +1 -1
  5. package/dist/src/app.js +12 -1
  6. package/dist/src/app.js.map +1 -1
  7. package/dist/src/cli.js +4 -3
  8. package/dist/src/cli.js.map +1 -1
  9. package/dist/src/trails/add-surface.d.ts +3 -3
  10. package/dist/src/trails/add-surface.d.ts.map +1 -1
  11. package/dist/src/trails/add-surface.js +46 -24
  12. package/dist/src/trails/add-surface.js.map +1 -1
  13. package/dist/src/trails/add-trail.d.ts +3 -1
  14. package/dist/src/trails/add-trail.d.ts.map +1 -1
  15. package/dist/src/trails/add-trail.js +49 -22
  16. package/dist/src/trails/add-trail.js.map +1 -1
  17. package/dist/src/trails/add-trailhead.d.ts +13 -0
  18. package/dist/src/trails/add-trailhead.d.ts.map +1 -0
  19. package/dist/src/trails/add-trailhead.js +88 -0
  20. package/dist/src/trails/add-trailhead.js.map +1 -0
  21. package/dist/src/trails/add-verify.d.ts +1 -1
  22. package/dist/src/trails/add-verify.d.ts.map +1 -1
  23. package/dist/src/trails/add-verify.js +17 -16
  24. package/dist/src/trails/add-verify.js.map +1 -1
  25. package/dist/src/trails/create-scaffold.d.ts +1 -1
  26. package/dist/src/trails/create-scaffold.d.ts.map +1 -1
  27. package/dist/src/trails/create-scaffold.js +34 -27
  28. package/dist/src/trails/create-scaffold.js.map +1 -1
  29. package/dist/src/trails/create.d.ts +9 -13
  30. package/dist/src/trails/create.d.ts.map +1 -1
  31. package/dist/src/trails/create.js +40 -35
  32. package/dist/src/trails/create.js.map +1 -1
  33. package/dist/src/trails/dev-clean.d.ts +9 -0
  34. package/dist/src/trails/dev-clean.d.ts.map +1 -0
  35. package/dist/src/trails/dev-clean.js +66 -0
  36. package/dist/src/trails/dev-clean.js.map +1 -0
  37. package/dist/src/trails/dev-reset.d.ts +6 -0
  38. package/dist/src/trails/dev-reset.d.ts.map +1 -0
  39. package/dist/src/trails/dev-reset.js +39 -0
  40. package/dist/src/trails/dev-reset.js.map +1 -0
  41. package/dist/src/trails/dev-stats.d.ts +7 -0
  42. package/dist/src/trails/dev-stats.d.ts.map +1 -0
  43. package/dist/src/trails/dev-stats.js +61 -0
  44. package/dist/src/trails/dev-stats.js.map +1 -0
  45. package/dist/src/trails/dev-support.d.ts +64 -0
  46. package/dist/src/trails/dev-support.d.ts.map +1 -0
  47. package/dist/src/trails/dev-support.js +181 -0
  48. package/dist/src/trails/dev-support.js.map +1 -0
  49. package/dist/src/trails/draft-promote.d.ts +18 -0
  50. package/dist/src/trails/draft-promote.d.ts.map +1 -0
  51. package/dist/src/trails/draft-promote.js +400 -0
  52. package/dist/src/trails/draft-promote.js.map +1 -0
  53. package/dist/src/trails/guide.d.ts +14 -4
  54. package/dist/src/trails/guide.d.ts.map +1 -1
  55. package/dist/src/trails/guide.js +22 -41
  56. package/dist/src/trails/guide.js.map +1 -1
  57. package/dist/src/trails/load-app.d.ts +9 -1
  58. package/dist/src/trails/load-app.d.ts.map +1 -1
  59. package/dist/src/trails/load-app.js +404 -13
  60. package/dist/src/trails/load-app.js.map +1 -1
  61. package/dist/src/trails/project.d.ts.map +1 -1
  62. package/dist/src/trails/project.js +14 -3
  63. package/dist/src/trails/project.js.map +1 -1
  64. package/dist/src/trails/survey.d.ts +6 -60
  65. package/dist/src/trails/survey.d.ts.map +1 -1
  66. package/dist/src/trails/survey.js +83 -182
  67. package/dist/src/trails/survey.js.map +1 -1
  68. package/dist/src/trails/topo-constants.d.ts +3 -0
  69. package/dist/src/trails/topo-constants.d.ts.map +1 -0
  70. package/dist/src/trails/topo-constants.js +3 -0
  71. package/dist/src/trails/topo-constants.js.map +1 -0
  72. package/dist/src/trails/topo-export.d.ts +19 -0
  73. package/dist/src/trails/topo-export.d.ts.map +1 -0
  74. package/dist/src/trails/topo-export.js +31 -0
  75. package/dist/src/trails/topo-export.js.map +1 -0
  76. package/dist/src/trails/topo-history.d.ts +20 -0
  77. package/dist/src/trails/topo-history.d.ts.map +1 -0
  78. package/dist/src/trails/topo-history.js +32 -0
  79. package/dist/src/trails/topo-history.js.map +1 -0
  80. package/dist/src/trails/topo-pin.d.ts +17 -0
  81. package/dist/src/trails/topo-pin.d.ts.map +1 -0
  82. package/dist/src/trails/topo-pin.js +31 -0
  83. package/dist/src/trails/topo-pin.js.map +1 -0
  84. package/dist/src/trails/topo-read-support.d.ts +58 -0
  85. package/dist/src/trails/topo-read-support.d.ts.map +1 -0
  86. package/dist/src/trails/topo-read-support.js +167 -0
  87. package/dist/src/trails/topo-read-support.js.map +1 -0
  88. package/dist/src/trails/topo-reports.d.ts +54 -0
  89. package/dist/src/trails/topo-reports.d.ts.map +1 -0
  90. package/dist/src/trails/topo-reports.js +128 -0
  91. package/dist/src/trails/topo-reports.js.map +1 -0
  92. package/dist/src/trails/topo-show.d.ts +23 -0
  93. package/dist/src/trails/topo-show.d.ts.map +1 -0
  94. package/dist/src/trails/topo-show.js +49 -0
  95. package/dist/src/trails/topo-show.js.map +1 -0
  96. package/dist/src/trails/topo-store-support.d.ts +13 -0
  97. package/dist/src/trails/topo-store-support.d.ts.map +1 -0
  98. package/dist/src/trails/topo-store-support.js +55 -0
  99. package/dist/src/trails/topo-store-support.js.map +1 -0
  100. package/dist/src/trails/topo-support.d.ts +76 -0
  101. package/dist/src/trails/topo-support.d.ts.map +1 -0
  102. package/dist/src/trails/topo-support.js +132 -0
  103. package/dist/src/trails/topo-support.js.map +1 -0
  104. package/dist/src/trails/topo-unpin.d.ts +20 -0
  105. package/dist/src/trails/topo-unpin.d.ts.map +1 -0
  106. package/dist/src/trails/topo-unpin.js +44 -0
  107. package/dist/src/trails/topo-unpin.js.map +1 -0
  108. package/dist/src/trails/topo-verify.d.ts +5 -0
  109. package/dist/src/trails/topo-verify.d.ts.map +1 -0
  110. package/dist/src/trails/topo-verify.js +24 -0
  111. package/dist/src/trails/topo-verify.js.map +1 -0
  112. package/dist/src/trails/topo.d.ts +5 -0
  113. package/dist/src/trails/topo.d.ts.map +1 -0
  114. package/dist/src/trails/topo.js +63 -0
  115. package/dist/src/trails/topo.js.map +1 -0
  116. package/dist/src/trails/warden.d.ts +3 -2
  117. package/dist/src/trails/warden.d.ts.map +1 -1
  118. package/dist/src/trails/warden.js +37 -27
  119. package/dist/src/trails/warden.js.map +1 -1
  120. package/dist/src/versions.d.ts +12 -0
  121. package/dist/src/versions.d.ts.map +1 -0
  122. package/dist/src/versions.js +23 -0
  123. package/dist/src/versions.js.map +1 -0
  124. package/dist/tsconfig.tsbuildinfo +1 -1
  125. package/package.json +8 -7
  126. package/src/__tests__/add-trail.test.ts +97 -0
  127. package/src/__tests__/create.test.ts +91 -27
  128. package/src/__tests__/draft-promote.test.ts +144 -0
  129. package/src/__tests__/guide.test.ts +10 -5
  130. package/src/__tests__/load-app.test.ts +406 -2
  131. package/src/__tests__/survey.test.ts +221 -60
  132. package/src/__tests__/topo-dev.test.ts +426 -0
  133. package/src/app.ts +24 -2
  134. package/src/clack.ts +1 -1
  135. package/src/cli.ts +4 -3
  136. package/src/trails/add-surface.ts +150 -0
  137. package/src/trails/add-trail.ts +46 -10
  138. package/src/trails/add-verify.ts +11 -6
  139. package/src/trails/create-scaffold.ts +16 -3
  140. package/src/trails/create.ts +76 -71
  141. package/src/trails/dev-clean.ts +77 -0
  142. package/src/trails/dev-reset.ts +45 -0
  143. package/src/trails/dev-stats.ts +67 -0
  144. package/src/trails/dev-support.ts +328 -0
  145. package/src/trails/draft-promote.ts +739 -0
  146. package/src/trails/guide.ts +23 -41
  147. package/src/trails/load-app.ts +556 -14
  148. package/src/trails/project.ts +17 -3
  149. package/src/trails/survey.ts +110 -285
  150. package/src/trails/topo-constants.ts +2 -0
  151. package/src/trails/topo-export.ts +35 -0
  152. package/src/trails/topo-history.ts +38 -0
  153. package/src/trails/topo-pin.ts +38 -0
  154. package/src/trails/topo-read-support.ts +329 -0
  155. package/src/trails/topo-reports.ts +228 -0
  156. package/src/trails/topo-show.ts +54 -0
  157. package/src/trails/topo-store-support.ts +104 -0
  158. package/src/trails/topo-support.ts +230 -0
  159. package/src/trails/topo-unpin.ts +56 -0
  160. package/src/trails/topo-verify.ts +25 -0
  161. package/src/trails/topo.ts +69 -0
  162. package/src/trails/warden.ts +13 -3
  163. package/src/versions.ts +43 -0
  164. package/tsconfig.tests.json +10 -0
  165. package/src/trails/add-trailhead.ts +0 -121
@@ -5,10 +5,24 @@
5
5
  import { existsSync } from 'node:fs';
6
6
  import { join } from 'node:path';
7
7
 
8
+ import { isDraftMarkedFile } from '@ontrails/warden';
9
+
8
10
  /** Return all TypeScript entries in a project's src directory. */
9
- const scanSourceEntries = (srcDir: string): string[] => [
10
- ...new Bun.Glob('*.ts').scanSync({ cwd: srcDir }),
11
- ];
11
+ const sourceEntryPriority = (entry: string): number => {
12
+ if (entry === 'app.ts') {
13
+ return 0;
14
+ }
15
+ return isDraftMarkedFile(entry) ? 2 : 1;
16
+ };
17
+
18
+ const scanSourceEntries = (srcDir: string): string[] =>
19
+ [...new Bun.Glob('*.ts').scanSync({ cwd: srcDir })].toSorted((a, b) => {
20
+ const priority = sourceEntryPriority(a) - sourceEntryPriority(b);
21
+ if (priority === 0) {
22
+ return a.localeCompare(b);
23
+ }
24
+ return priority;
25
+ });
12
26
 
13
27
  /** Resolve an entry to an app import if it contains topo(). */
14
28
  const toTopoImport = async (
@@ -1,256 +1,47 @@
1
1
  /**
2
2
  * `survey` trail -- Full topo introspection.
3
3
  *
4
- * Lists trails, shows detail for individual trails, generates trailhead maps,
4
+ * Lists trails, shows detail for individual trails, generates surface maps,
5
5
  * and diffs against previous versions.
6
6
  */
7
7
 
8
- import type { Topo, Trail } from '@ontrails/core';
9
- import { Result, trail } from '@ontrails/core';
8
+ import { join } from 'node:path';
9
+
10
+ import type { Topo } from '@ontrails/core';
11
+ import { NotFoundError, Result, trail } from '@ontrails/core';
10
12
  import type { DiffResult } from '@ontrails/schema';
11
13
  import {
12
- diffTrailheadMaps,
13
- generateOpenApiSpec,
14
- generateTrailheadMap,
15
- hashTrailheadMap,
16
- readTrailheadMap,
17
- writeTrailheadLock,
18
- writeTrailheadMap,
14
+ deriveSurfaceMapDiff,
15
+ deriveOpenApiSpec,
16
+ deriveSurfaceMap,
17
+ readSurfaceMap,
19
18
  } from '@ontrails/schema';
20
19
  import { z } from 'zod';
21
20
 
22
- import { loadApp } from './load-app.js';
21
+ import { loadApp, loadFreshAppLease } from './load-app.js';
22
+ import {
23
+ buildCurrentTopoBrief,
24
+ buildCurrentTopoDetail,
25
+ buildCurrentTopoList,
26
+ } from './topo-read-support.js';
27
+ import { exportCurrentTopo } from './topo-store-support.js';
28
+
29
+ export {
30
+ deriveBriefReport,
31
+ deriveResourceDetail,
32
+ deriveSurveyList,
33
+ deriveTrailDetail,
34
+ } from './topo-reports.js';
35
+ export type {
36
+ BriefReport,
37
+ SurveyListReport,
38
+ TrailDetailReport,
39
+ } from './topo-reports.js';
23
40
 
24
41
  // ---------------------------------------------------------------------------
25
42
  // Brief report (formerly scout)
26
43
  // ---------------------------------------------------------------------------
27
44
 
28
- export interface BriefReport {
29
- readonly name: string;
30
- readonly version: string;
31
- readonly contractVersion: string;
32
- readonly features: {
33
- readonly provisions: boolean;
34
- readonly outputSchemas: boolean;
35
- readonly examples: boolean;
36
- readonly detours: boolean;
37
- readonly signals: boolean;
38
- };
39
- readonly trails: number;
40
- readonly signals: number;
41
- readonly provisions: number;
42
- }
43
-
44
- export interface SurveyListReport {
45
- readonly count: number;
46
- readonly entries: readonly {
47
- readonly examples: number;
48
- readonly id: string;
49
- readonly kind: string;
50
- readonly safety: string;
51
- }[];
52
- readonly provisionCount: number;
53
- readonly provisions: readonly {
54
- readonly description: string | null;
55
- readonly health: 'available' | 'none';
56
- readonly id: string;
57
- readonly kind: 'provision';
58
- readonly lifetime: 'singleton';
59
- readonly usedBy: readonly string[];
60
- }[];
61
- }
62
-
63
- export interface TrailDetailReport {
64
- readonly description: string | null;
65
- readonly detours: Trail<unknown, unknown>['detours'] | null;
66
- readonly examples: readonly unknown[];
67
- readonly crosses: readonly string[];
68
- readonly id: string;
69
- readonly intent: 'read' | 'write' | 'destroy';
70
- readonly kind: string;
71
- readonly safety: string;
72
- readonly provisions: readonly string[];
73
- }
74
-
75
- /** Check if a trail has a specific feature. */
76
- const trailHas = (raw: Record<string, unknown>, key: string): boolean => {
77
- if (key === 'examples') {
78
- return Array.isArray(raw[key]) && (raw[key] as unknown[]).length > 0;
79
- }
80
- return Boolean(raw[key]);
81
- };
82
-
83
- /** Detect which features are used across trails. */
84
- const detectFeatures = (
85
- app: Topo
86
- ): {
87
- hasDetours: boolean;
88
- hasExamples: boolean;
89
- hasOutputSchemas: boolean;
90
- hasProvisions: boolean;
91
- } => {
92
- const trails = [...app.trails.values()].map(
93
- (item) => item as unknown as Record<string, unknown>
94
- );
95
- return {
96
- hasDetours: trails.some((r) => trailHas(r, 'detours')),
97
- hasExamples: trails.some((r) => trailHas(r, 'examples')),
98
- hasOutputSchemas: trails.some((r) => trailHas(r, 'output')),
99
- hasProvisions: trails.some(
100
- (r) =>
101
- Array.isArray(r['provisions']) &&
102
- (r['provisions'] as unknown[]).length > 0
103
- ),
104
- };
105
- };
106
-
107
- /** Generate a compact capability report for the given topo. */
108
- export const generateBriefReport = (app: Topo): BriefReport => {
109
- const { hasDetours, hasExamples, hasOutputSchemas, hasProvisions } =
110
- detectFeatures(app);
111
-
112
- return {
113
- contractVersion: '2026-03',
114
- features: {
115
- detours: hasDetours,
116
- examples: hasExamples,
117
- outputSchemas: hasOutputSchemas,
118
- provisions: hasProvisions,
119
- signals: app.signals.size > 0,
120
- },
121
- name: app.name,
122
- provisions: app.provisions.size,
123
- signals: app.signals.size,
124
- trails: app.trails.size,
125
- version: '0.1.0',
126
- };
127
- };
128
-
129
- // ---------------------------------------------------------------------------
130
- // Formatting helpers
131
- // ---------------------------------------------------------------------------
132
-
133
- const safetyLabel = (entry: {
134
- intent?: 'read' | 'write' | 'destroy';
135
- }): string => {
136
- if (entry.intent === 'destroy') {
137
- return 'destroy';
138
- }
139
- if (entry.intent === 'read') {
140
- return 'read';
141
- }
142
- return '-';
143
- };
144
-
145
- const buildProvisionUsage = (
146
- app: Topo
147
- ): ReadonlyMap<string, readonly string[]> => {
148
- const usage = new Map<string, string[]>();
149
-
150
- for (const trailDef of app.list()) {
151
- for (const declaredProvision of trailDef.provisions) {
152
- const users = usage.get(declaredProvision.id) ?? [];
153
- users.push(trailDef.id);
154
- usage.set(declaredProvision.id, users);
155
- }
156
- }
157
-
158
- return new Map(
159
- [...usage.entries()].map(([id, users]) => [id, users.toSorted()] as const)
160
- );
161
- };
162
-
163
- const provisionHealthStatus = (provision: {
164
- health?: unknown;
165
- }): 'available' | 'none' =>
166
- provision.health === undefined ? 'none' : 'available';
167
-
168
- const formatProvisionList = (app: Topo): SurveyListReport['provisions'] => {
169
- const usage = buildProvisionUsage(app);
170
- return app
171
- .listProvisions()
172
- .map((provision) => ({
173
- description: provision.description ?? null,
174
- health: provisionHealthStatus(provision),
175
- id: provision.id,
176
- kind: provision.kind,
177
- lifetime: 'singleton' as const,
178
- usedBy: usage.get(provision.id) ?? [],
179
- }))
180
- .toSorted((a, b) => a.id.localeCompare(b.id));
181
- };
182
-
183
- export const generateSurveyList = (app: Topo): SurveyListReport => {
184
- const items = app.list();
185
- const entries = items.map((item) => {
186
- const safety = safetyLabel(
187
- item as unknown as { intent?: 'read' | 'write' | 'destroy' }
188
- );
189
- const examples = Array.isArray(
190
- (item as unknown as { examples?: unknown[] }).examples
191
- )
192
- ? (item as unknown as { examples: unknown[] }).examples.length
193
- : 0;
194
-
195
- return {
196
- examples,
197
- id: item.id,
198
- kind: item.kind,
199
- safety,
200
- };
201
- });
202
-
203
- const provisions = formatProvisionList(app);
204
-
205
- return {
206
- count: items.length,
207
- entries,
208
- provisionCount: provisions.length,
209
- provisions,
210
- };
211
- };
212
-
213
- /**
214
- * Build a human-readable detail view for a single trail.
215
- *
216
- * Overlaps with `trailToEntry` in `@ontrails/schema` which builds the
217
- * trailhead-map entry. The two serve different audiences (human display vs
218
- * machine-diffable trailhead map) so they are kept separate.
219
- */
220
- export const generateTrailDetail = (
221
- item: Trail<unknown, unknown>
222
- ): TrailDetailReport => {
223
- const safety = safetyLabel(
224
- item as unknown as { intent?: 'read' | 'write' | 'destroy' }
225
- );
226
-
227
- return {
228
- crosses: item.crosses.toSorted(),
229
- description: item.description ?? null,
230
- detours: item.detours ?? null,
231
- examples: item.examples ?? [],
232
- id: item.id,
233
- intent: item.intent,
234
- kind: item.kind,
235
- provisions: item.provisions.map((provision) => provision.id).toSorted(),
236
- safety,
237
- };
238
- };
239
-
240
- const formatProvisionDetail = (app: Topo, provisionId: string): object => {
241
- const item = app.getProvision(provisionId);
242
- const usedBy = buildProvisionUsage(app).get(provisionId) ?? [];
243
-
244
- return {
245
- description: item?.description ?? null,
246
- health: item ? provisionHealthStatus(item) : 'none',
247
- id: provisionId,
248
- kind: 'provision',
249
- lifetime: 'singleton',
250
- usedBy,
251
- };
252
- };
253
-
254
45
  const formatDiff = (diff: DiffResult): object => ({
255
46
  breaking: diff.breaking,
256
47
  hasBreaking: diff.hasBreaking,
@@ -260,19 +51,20 @@ const formatDiff = (diff: DiffResult): object => ({
260
51
 
261
52
  const buildSurveyDiff = async (
262
53
  app: Topo,
54
+ rootDir: string,
263
55
  breakingOnly: boolean
264
56
  ): Promise<Result<object, Error>> => {
265
- const currentMap = generateTrailheadMap(app);
266
- const previousMap = await readTrailheadMap();
57
+ const currentMap = deriveSurfaceMap(app);
58
+ const previousMap = await readSurfaceMap({ dir: join(rootDir, '.trails') });
267
59
  if (!previousMap) {
268
60
  return Result.err(
269
- new Error(
270
- 'No previous trailhead map found. Run `trails survey generate` first.'
61
+ new NotFoundError(
62
+ 'No saved surface map found. Run `trails topo export` first.'
271
63
  )
272
64
  );
273
65
  }
274
66
 
275
- const diff = diffTrailheadMaps(previousMap, currentMap);
67
+ const diff = deriveSurfaceMapDiff(previousMap, currentMap);
276
68
  return Result.ok(
277
69
  breakingOnly
278
70
  ? formatDiff({
@@ -287,32 +79,37 @@ const buildSurveyDiff = async (
287
79
 
288
80
  const buildSurveyDetail = (
289
81
  app: Topo,
290
- trailId: string
82
+ trailId: string,
83
+ rootDir: string
291
84
  ): Result<object, Error> => {
292
- const item = app.get(trailId);
293
- if (item) {
294
- return Result.ok(generateTrailDetail(item as Trail<unknown, unknown>));
85
+ const detail = buildCurrentTopoDetail(app, trailId, { rootDir });
86
+ if (detail !== undefined) {
87
+ return Result.ok(detail);
295
88
  }
296
- if (app.getProvision(trailId)) {
297
- return Result.ok(formatProvisionDetail(app, trailId));
298
- }
299
- return Result.err(new Error(`Trail or provision not found: ${trailId}`));
89
+ return Result.err(
90
+ new NotFoundError(`Trail or resource not found: ${trailId}`)
91
+ );
300
92
  };
301
93
 
302
94
  const buildSurveyGenerate = async (
303
- app: Topo
95
+ app: Topo,
96
+ rootDir: string
304
97
  ): Promise<Result<object, Error>> => {
305
- const trailheadMap = generateTrailheadMap(app);
306
- const mapPath = await writeTrailheadMap(trailheadMap);
307
- const hash = hashTrailheadMap(trailheadMap);
308
- const lockPath = await writeTrailheadLock(hash);
309
- return Result.ok({ hash, lockPath, mapPath });
98
+ const exported = await exportCurrentTopo(app, { rootDir });
99
+ if (exported.isErr()) {
100
+ return exported;
101
+ }
102
+ return Result.ok({
103
+ hash: exported.value.hash,
104
+ lockPath: exported.value.lockPath,
105
+ mapPath: exported.value.mapPath,
106
+ });
310
107
  };
311
108
 
312
109
  interface SurveyInput {
313
110
  breakingOnly: boolean;
314
111
  brief: boolean;
315
- diff?: string | undefined;
112
+ diffSaved: boolean;
316
113
  generate: boolean;
317
114
  openapi: boolean;
318
115
  trailId?: string | undefined;
@@ -323,39 +120,45 @@ type SurveyMode = 'brief' | 'detail' | 'diff' | 'generate' | 'list' | 'openapi';
323
120
  /** Ordered mode checks — first truthy predicate wins, otherwise 'list'. */
324
121
  const modeChecks: readonly [(input: SurveyInput) => boolean, SurveyMode][] = [
325
122
  [(i) => i.brief, 'brief'],
326
- [(i) => Boolean(i.diff), 'diff'],
123
+ [(i) => i.diffSaved, 'diff'],
327
124
  [(i) => Boolean(i.trailId), 'detail'],
328
125
  [(i) => i.generate, 'generate'],
329
126
  [(i) => i.openapi, 'openapi'],
330
127
  ];
331
128
 
332
129
  /** Determine which survey mode was requested, falling back to 'list'. */
333
- const resolveSurveyMode = (input: SurveyInput): SurveyMode =>
130
+ const deriveSurveyMode = (input: SurveyInput): SurveyMode =>
334
131
  modeChecks.find(([predicate]) => predicate(input))?.[1] ?? 'list';
335
132
 
336
133
  type SurveyHandler = (
337
134
  app: Topo,
338
- input: SurveyInput
135
+ input: SurveyInput,
136
+ rootDir: string
339
137
  ) => Result<object, Error> | Promise<Result<object, Error>>;
340
138
 
341
139
  /** Handlers keyed by survey mode. */
342
140
  const surveyHandlers: Record<SurveyMode, SurveyHandler> = {
343
- brief: (app) => Result.ok(generateBriefReport(app)),
344
- detail: (app, input) => buildSurveyDetail(app, input.trailId ?? ''),
345
- diff: (app, input) => buildSurveyDiff(app, input.breakingOnly),
346
- generate: (app) => buildSurveyGenerate(app),
347
- list: (app) => Result.ok(generateSurveyList(app)),
348
- openapi: (app) => Result.ok(generateOpenApiSpec(app)),
141
+ brief: (app, _input, rootDir) =>
142
+ Result.ok(buildCurrentTopoBrief(app, { rootDir })),
143
+ detail: (app, input, rootDir) =>
144
+ buildSurveyDetail(app, input.trailId ?? '', rootDir),
145
+ diff: (app, input, rootDir) =>
146
+ buildSurveyDiff(app, rootDir, input.breakingOnly),
147
+ generate: (app, _input, rootDir) => buildSurveyGenerate(app, rootDir),
148
+ list: (app, _input, rootDir) =>
149
+ Result.ok(buildCurrentTopoList(app, { rootDir })),
150
+ openapi: (app) => Result.ok(deriveOpenApiSpec(app)),
349
151
  };
350
152
 
351
153
  /** Dispatch to the appropriate survey sub-command based on input flags. */
352
154
  const dispatchSurvey = (
353
155
  app: Topo,
354
- input: SurveyInput
156
+ input: SurveyInput,
157
+ rootDir: string
355
158
  ): Result<object, Error> | Promise<Result<object, Error>> => {
356
- const mode = resolveSurveyMode(input);
159
+ const mode = deriveSurveyMode(input);
357
160
  const handler = surveyHandlers[mode];
358
- return handler(app, input);
161
+ return handler(app, input, rootDir);
359
162
  };
360
163
 
361
164
  // ---------------------------------------------------------------------------
@@ -364,13 +167,35 @@ const dispatchSurvey = (
364
167
 
365
168
  export const surveyTrail = trail('survey', {
366
169
  blaze: async (input, ctx) => {
367
- const app = await loadApp(input.module, ctx.cwd ?? '.');
368
- return dispatchSurvey(app, input);
170
+ const rootDir = ctx.cwd ?? '.';
171
+ const mode = deriveSurveyMode(input);
172
+ // Fresh load only for diffSaved: comparing against a previously-saved
173
+ // surface map requires the current app's source state, not any cached
174
+ // module graph that a prior import may have frozen. Other modes read
175
+ // the in-memory topo and benefit from the standard import cache.
176
+ //
177
+ // For diff specifically, use a disposable lease rather than retained
178
+ // fresh mirrors — the returned diff result is serialisable data, not
179
+ // a Topo reference with deferred imports, so the mirror can be
180
+ // released the moment dispatchSurvey returns. That keeps MCP/dev
181
+ // sessions that poll diff repeatedly from growing .trails-tmp/
182
+ // without bound.
183
+ if (mode === 'diff') {
184
+ const lease = await loadFreshAppLease(input.module, rootDir);
185
+ try {
186
+ return await dispatchSurvey(lease.app, input, rootDir);
187
+ } finally {
188
+ lease.release();
189
+ }
190
+ }
191
+
192
+ const app = await loadApp(input.module, rootDir);
193
+ return dispatchSurvey(app, input, rootDir);
369
194
  },
370
195
  description: 'Full topo introspection',
371
196
  examples: [
372
197
  {
373
- description: 'Lists all registered trails with safety and trailhead info',
198
+ description: 'Lists all registered trails with safety and surface info',
374
199
  input: { module: './src/app.ts' },
375
200
  name: 'List all trails',
376
201
  },
@@ -391,15 +216,15 @@ export const surveyTrail = trail('survey', {
391
216
  .default(false)
392
217
  .describe('Only show breaking changes'),
393
218
  brief: z.boolean().default(false).describe('Quick capability summary'),
394
- diff: z.string().optional().describe('Diff against a git ref'),
219
+ diffSaved: z
220
+ .boolean()
221
+ .default(false)
222
+ .describe('Diff against the saved local surface map'),
395
223
  generate: z
396
224
  .boolean()
397
225
  .default(false)
398
- .describe('Generate trailhead map and lock file'),
399
- module: z
400
- .string()
401
- .default('./src/app.ts')
402
- .describe('Path to the app module'),
226
+ .describe('Generate surface map and lock file'),
227
+ module: z.string().optional().describe('Path to the app module'),
403
228
  openapi: z.boolean().default(false).describe('Output OpenAPI 3.1 spec'),
404
229
  trailId: z.string().optional().describe('Trail ID for detail view'),
405
230
  }),
@@ -415,13 +240,13 @@ export const surveyTrail = trail('survey', {
415
240
  safety: z.string(),
416
241
  })
417
242
  ),
418
- provisionCount: z.number(),
419
- provisions: z.array(
243
+ resourceCount: z.number(),
244
+ resources: z.array(
420
245
  z.object({
421
246
  description: z.string().nullable(),
422
247
  health: z.enum(['available', 'none']),
423
248
  id: z.string(),
424
- kind: z.literal('provision'),
249
+ kind: z.literal('resource'),
425
250
  lifetime: z.literal('singleton'),
426
251
  usedBy: z.array(z.string()),
427
252
  })
@@ -433,11 +258,11 @@ export const surveyTrail = trail('survey', {
433
258
  detours: z.boolean(),
434
259
  examples: z.boolean(),
435
260
  outputSchemas: z.boolean(),
436
- provisions: z.boolean(),
261
+ resources: z.boolean(),
437
262
  signals: z.boolean(),
438
263
  }),
439
264
  name: z.string(),
440
- provisions: z.number(),
265
+ resources: z.number(),
441
266
  signals: z.number(),
442
267
  trails: z.number(),
443
268
  version: z.string(),
@@ -456,14 +281,14 @@ export const surveyTrail = trail('survey', {
456
281
  id: z.string(),
457
282
  intent: z.enum(['read', 'write', 'destroy']),
458
283
  kind: z.string(),
459
- provisions: z.array(z.string()),
284
+ resources: z.array(z.string()),
460
285
  safety: z.string(),
461
286
  }),
462
287
  z.object({
463
288
  description: z.string().nullable(),
464
289
  health: z.enum(['available', 'none']),
465
290
  id: z.string(),
466
- kind: z.literal('provision'),
291
+ kind: z.literal('resource'),
467
292
  lifetime: z.literal('singleton'),
468
293
  usedBy: z.array(z.string()),
469
294
  }),
@@ -0,0 +1,2 @@
1
+ export const REPORT_CONTRACT_VERSION = '2026-03';
2
+ export const REPORT_VERSION = '0.1.0';
@@ -0,0 +1,35 @@
1
+ import { trail } from '@ontrails/core';
2
+ import { z } from 'zod';
3
+
4
+ import { loadApp } from './load-app.js';
5
+ import { exportCurrentTopo } from './topo-store-support.js';
6
+ import {
7
+ createIsolatedExampleInput,
8
+ topoSnapshotOutput,
9
+ } from './topo-support.js';
10
+
11
+ export const topoExportTrail = trail('topo.export', {
12
+ blaze: async (input, ctx) => {
13
+ const rootDir = input.rootDir ?? ctx.cwd ?? process.cwd();
14
+ const app = await loadApp(input.module, rootDir);
15
+ return exportCurrentTopo(app, { rootDir });
16
+ },
17
+ description: 'Export the current topo to .trails artifacts',
18
+ examples: [
19
+ {
20
+ input: createIsolatedExampleInput('topo-export'),
21
+ name: 'Write the current topo export',
22
+ },
23
+ ],
24
+ input: z.object({
25
+ module: z.string().optional().describe('Path to the app module'),
26
+ rootDir: z.string().optional().describe('Workspace root directory'),
27
+ }),
28
+ intent: 'write',
29
+ output: z.object({
30
+ hash: z.string(),
31
+ lockPath: z.string(),
32
+ mapPath: z.string(),
33
+ snapshot: topoSnapshotOutput,
34
+ }),
35
+ });
@@ -0,0 +1,38 @@
1
+ import { Result, trail } from '@ontrails/core';
2
+ import { z } from 'zod';
3
+
4
+ import {
5
+ DEFAULT_TOPO_HISTORY_LIMIT,
6
+ createIsolatedExampleInput,
7
+ listTopoHistory,
8
+ topoSnapshotOutput,
9
+ } from './topo-support.js';
10
+
11
+ export const topoHistoryTrail = trail('topo.history', {
12
+ blaze: (input, ctx) => {
13
+ const rootDir = input.rootDir ?? ctx.cwd ?? process.cwd();
14
+ return Result.ok(listTopoHistory({ limit: input.limit, rootDir }));
15
+ },
16
+ description: 'List saved topo snapshots, including pinned references',
17
+ examples: [
18
+ {
19
+ input: createIsolatedExampleInput('topo-history'),
20
+ name: 'Show topo history',
21
+ },
22
+ ],
23
+ input: z.object({
24
+ limit: z
25
+ .number()
26
+ .default(DEFAULT_TOPO_HISTORY_LIMIT)
27
+ .describe('Maximum number of snapshots to return'),
28
+ rootDir: z.string().optional().describe('Workspace root directory'),
29
+ }),
30
+ intent: 'read',
31
+ output: z.object({
32
+ dbPath: z.string(),
33
+ limit: z.number(),
34
+ pinnedCount: z.number(),
35
+ snapshotCount: z.number(),
36
+ snapshots: z.array(topoSnapshotOutput),
37
+ }),
38
+ });
@@ -0,0 +1,38 @@
1
+ import { Result, trail } from '@ontrails/core';
2
+ import { z } from 'zod';
3
+
4
+ import { loadApp } from './load-app.js';
5
+ import {
6
+ createIsolatedExampleInput,
7
+ pinCurrentTopoSnapshot,
8
+ topoSnapshotOutput,
9
+ } from './topo-support.js';
10
+
11
+ export const topoPinTrail = trail('topo.pin', {
12
+ blaze: async (input, ctx) => {
13
+ const rootDir = input.rootDir ?? ctx.cwd ?? process.cwd();
14
+ const app = await loadApp(input.module, rootDir);
15
+ return Result.ok(
16
+ pinCurrentTopoSnapshot(app, { name: input.name, rootDir })
17
+ );
18
+ },
19
+ description: 'Pin the current topo under a durable name',
20
+ examples: [
21
+ {
22
+ input: {
23
+ ...createIsolatedExampleInput('topo-pin'),
24
+ name: 'before-auth-refactor',
25
+ },
26
+ name: 'Pin the current topo',
27
+ },
28
+ ],
29
+ input: z.object({
30
+ module: z.string().optional().describe('Path to the app module'),
31
+ name: z.string().describe('Pin name'),
32
+ rootDir: z.string().optional().describe('Workspace root directory'),
33
+ }),
34
+ intent: 'write',
35
+ output: z.object({
36
+ snapshot: topoSnapshotOutput,
37
+ }),
38
+ });