@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
@@ -0,0 +1,329 @@
1
+ /**
2
+ * Read-only topo store consumer helpers.
3
+ *
4
+ * Extracted from topo-support.ts to isolate read-only store consumer helpers,
5
+ * keeping module boundaries clean.
6
+ */
7
+
8
+ import { existsSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+
11
+ import {
12
+ ConflictError,
13
+ createTopoStore,
14
+ InternalError,
15
+ listTopoSnapshots,
16
+ NotFoundError,
17
+ Result,
18
+ } from '@ontrails/core';
19
+ import type { Topo, TopoSnapshot } from '@ontrails/core';
20
+ import {
21
+ deriveTrailsDbPath,
22
+ deriveTrailsDir,
23
+ } from '@ontrails/core/internal/trails-db';
24
+ import { readSurfaceLockData } from '@ontrails/schema';
25
+
26
+ import type { BriefReport, SurveyListReport } from './topo-reports.js';
27
+ import type { TopoSummaryReport, TopoVerifyReport } from './topo-support.js';
28
+ import { REPORT_CONTRACT_VERSION, REPORT_VERSION } from './topo-constants.js';
29
+ import {
30
+ createCurrentTopoSnapshot,
31
+ deriveRootDir,
32
+ LOCK_PATH,
33
+ } from './topo-support.js';
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Internal types
37
+ // ---------------------------------------------------------------------------
38
+
39
+ interface StoredSurfaceMapEntry {
40
+ readonly detours?: readonly {
41
+ readonly on: string;
42
+ readonly maxAttempts: number;
43
+ }[];
44
+ readonly kind: 'resource' | 'signal' | 'trail';
45
+ }
46
+
47
+ interface CurrentTrailDetail {
48
+ readonly crosses: string[];
49
+ readonly description: string | null;
50
+ readonly detours:
51
+ | readonly { readonly on: string; readonly maxAttempts: number }[]
52
+ | null;
53
+ readonly examples: unknown[];
54
+ readonly id: string;
55
+ readonly intent: 'destroy' | 'read' | 'write';
56
+ readonly kind: string;
57
+ readonly pattern: string | null;
58
+ readonly resources: string[];
59
+ readonly safety: string;
60
+ }
61
+
62
+ interface CurrentResourceDetail {
63
+ readonly description: string | null;
64
+ readonly health: 'available' | 'none';
65
+ readonly id: string;
66
+ readonly kind: 'resource';
67
+ readonly lifetime: 'singleton';
68
+ readonly usedBy: string[];
69
+ }
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Store helpers
73
+ // ---------------------------------------------------------------------------
74
+
75
+ const topoStoreRef = (snapshotId: string) => ({ snapshotId }) as const;
76
+
77
+ const hasCommittedLock = (trailsDir: string): boolean =>
78
+ existsSync(join(trailsDir, 'trails.lock'));
79
+
80
+ const readSurfaceEntries = (
81
+ surfaceMapJson: string
82
+ ): readonly StoredSurfaceMapEntry[] =>
83
+ (
84
+ JSON.parse(surfaceMapJson) as {
85
+ readonly entries: readonly StoredSurfaceMapEntry[];
86
+ }
87
+ ).entries;
88
+
89
+ const buildBriefReportFromStore = (
90
+ app: Topo,
91
+ store: ReturnType<typeof createTopoStore>,
92
+ ref: ReturnType<typeof topoStoreRef>,
93
+ snapshot: TopoSnapshot
94
+ ): BriefReport => {
95
+ const trails = store.trails.list({ snapshot: ref });
96
+ const exportRecord = store.exports.get(ref);
97
+ const trailEntries =
98
+ exportRecord === undefined
99
+ ? []
100
+ : readSurfaceEntries(exportRecord.surfaceMapJson).filter(
101
+ (entry) => entry.kind === 'trail'
102
+ );
103
+
104
+ return {
105
+ contractVersion: REPORT_CONTRACT_VERSION,
106
+ features: {
107
+ detours: trailEntries.some((entry) => (entry.detours ?? []).length > 0),
108
+ examples: trails.some((trail) => trail.hasExamples),
109
+ outputSchemas: trails.some((trail) => trail.hasOutput),
110
+ resources: snapshot.resourceCount > 0,
111
+ signals: snapshot.signalCount > 0,
112
+ },
113
+ name: app.name,
114
+ resources: snapshot.resourceCount,
115
+ signals: snapshot.signalCount,
116
+ trails: snapshot.trailCount,
117
+ version: REPORT_VERSION,
118
+ };
119
+ };
120
+
121
+ const buildSurveyListFromStore = (
122
+ store: ReturnType<typeof createTopoStore>,
123
+ ref: ReturnType<typeof topoStoreRef>
124
+ ): SurveyListReport => {
125
+ const trails = store.trails.list({ snapshot: ref });
126
+ const resources = store.resources.list({ snapshot: ref });
127
+
128
+ return {
129
+ count: trails.length,
130
+ entries: trails.map((trail) => ({
131
+ examples: trail.exampleCount,
132
+ id: trail.id,
133
+ kind: trail.kind,
134
+ safety: trail.safety,
135
+ })),
136
+ resourceCount: resources.length,
137
+ resources: resources.map((resource) => ({
138
+ description: resource.description,
139
+ health: resource.health,
140
+ id: resource.id,
141
+ kind: resource.kind,
142
+ lifetime: resource.lifetime,
143
+ usedBy: resource.usedBy,
144
+ })),
145
+ };
146
+ };
147
+
148
+ const buildTrailDetailFromStore = (
149
+ detail: NonNullable<
150
+ ReturnType<ReturnType<typeof createTopoStore>['trails']['get']>
151
+ >
152
+ ): CurrentTrailDetail => ({
153
+ crosses: [...detail.crosses],
154
+ description: detail.description,
155
+ detours: detail.detours,
156
+ examples: [...detail.examples],
157
+ id: detail.id,
158
+ intent: detail.intent,
159
+ kind: detail.kind,
160
+ pattern: detail.pattern,
161
+ resources: [...detail.resources],
162
+ safety: detail.safety,
163
+ });
164
+
165
+ const buildResourceDetailFromStore = (
166
+ resource: NonNullable<
167
+ ReturnType<ReturnType<typeof createTopoStore>['resources']['get']>
168
+ >
169
+ ): CurrentResourceDetail => ({
170
+ description: resource.description,
171
+ health: resource.health,
172
+ id: resource.id,
173
+ kind: resource.kind,
174
+ lifetime: resource.lifetime,
175
+ usedBy: [...resource.usedBy],
176
+ });
177
+
178
+ // ---------------------------------------------------------------------------
179
+ // withCurrentTopoStore
180
+ // ---------------------------------------------------------------------------
181
+
182
+ /**
183
+ * Run a read callback against the latest topo store state.
184
+ *
185
+ * Uses the most recent existing snapshot when available, only creating a new
186
+ * snapshot when no prior snapshot exists. This avoids unbounded snapshot
187
+ * accumulation from
188
+ * read-only operations like survey, guide, and show.
189
+ */
190
+ const withCurrentTopoStore = <T>(
191
+ app: Topo,
192
+ rootDir: string,
193
+ read: (
194
+ store: ReturnType<typeof createTopoStore>,
195
+ ref: ReturnType<typeof topoStoreRef>,
196
+ snapshot: TopoSnapshot
197
+ ) => T
198
+ ): T => {
199
+ const [existingSnapshot] = listTopoSnapshots({ limit: 1, rootDir });
200
+ const snapshot =
201
+ existingSnapshot ?? createCurrentTopoSnapshot(app, { rootDir });
202
+ const store = createTopoStore({ rootDir });
203
+ return read(store, topoStoreRef(snapshot.id), snapshot);
204
+ };
205
+
206
+ // ---------------------------------------------------------------------------
207
+ // Public read-only consumers
208
+ // ---------------------------------------------------------------------------
209
+
210
+ export const buildTopoSummary = (
211
+ app: Topo,
212
+ options?: { readonly rootDir?: string }
213
+ ): TopoSummaryReport => {
214
+ const rootDir = deriveRootDir(options?.rootDir);
215
+ const trailsDir = deriveTrailsDir({ rootDir });
216
+ return withCurrentTopoStore(app, rootDir, (store, ref, snapshot) => ({
217
+ app: buildBriefReportFromStore(app, store, ref, snapshot),
218
+ dbPath: deriveTrailsDbPath({ rootDir }),
219
+ list: buildSurveyListFromStore(store, ref),
220
+ lockExists: hasCommittedLock(trailsDir),
221
+ lockPath: LOCK_PATH,
222
+ }));
223
+ };
224
+
225
+ export const buildCurrentTopoBrief = (
226
+ app: Topo,
227
+ options?: { readonly rootDir?: string }
228
+ ): BriefReport => {
229
+ const rootDir = deriveRootDir(options?.rootDir);
230
+ return withCurrentTopoStore(app, rootDir, (store, ref, snapshot) =>
231
+ buildBriefReportFromStore(app, store, ref, snapshot)
232
+ );
233
+ };
234
+
235
+ export const buildCurrentTopoList = (
236
+ app: Topo,
237
+ options?: { readonly rootDir?: string }
238
+ ): SurveyListReport => {
239
+ const rootDir = deriveRootDir(options?.rootDir);
240
+ return withCurrentTopoStore(app, rootDir, (store, ref) =>
241
+ buildSurveyListFromStore(store, ref)
242
+ );
243
+ };
244
+
245
+ export const buildCurrentGuideEntries = (
246
+ app: Topo,
247
+ options?: { readonly rootDir?: string }
248
+ ): readonly {
249
+ readonly description: string;
250
+ readonly exampleCount: number;
251
+ readonly id: string;
252
+ readonly kind: string;
253
+ }[] => {
254
+ const rootDir = deriveRootDir(options?.rootDir);
255
+ return withCurrentTopoStore(app, rootDir, (store, ref) =>
256
+ store.trails.list({ snapshot: ref }).map((trail) => ({
257
+ description: trail.description ?? '(no description)',
258
+ exampleCount: trail.exampleCount,
259
+ id: trail.id,
260
+ kind: trail.kind,
261
+ }))
262
+ );
263
+ };
264
+
265
+ export const buildCurrentTopoDetail = (
266
+ app: Topo,
267
+ id: string,
268
+ options?: { readonly rootDir?: string }
269
+ ): CurrentResourceDetail | CurrentTrailDetail | undefined => {
270
+ const rootDir = deriveRootDir(options?.rootDir);
271
+ return withCurrentTopoStore(app, rootDir, (store, ref) => {
272
+ const trail = store.trails.get(id, { snapshot: ref });
273
+ if (trail !== undefined) {
274
+ return buildTrailDetailFromStore(trail);
275
+ }
276
+
277
+ const resource = store.resources.get(id, { snapshot: ref });
278
+ return resource === undefined
279
+ ? undefined
280
+ : buildResourceDetailFromStore(resource);
281
+ });
282
+ };
283
+
284
+ export const verifyCurrentTopo = async (
285
+ app: Topo,
286
+ options?: { readonly rootDir?: string }
287
+ ): Promise<Result<TopoVerifyReport, Error>> => {
288
+ const rootDir = deriveRootDir(options?.rootDir);
289
+ const committedLock = await readSurfaceLockData({
290
+ dir: deriveTrailsDir({ rootDir }),
291
+ });
292
+
293
+ if (committedLock === null) {
294
+ return Result.err(
295
+ new NotFoundError(
296
+ 'No committed trails.lock found. Run `trails topo export` first.'
297
+ )
298
+ );
299
+ }
300
+
301
+ const currentHash = withCurrentTopoStore(
302
+ app,
303
+ rootDir,
304
+ (store, ref) => store.exports.get(ref)?.surfaceHash
305
+ );
306
+
307
+ if (currentHash === undefined) {
308
+ return Result.err(
309
+ new InternalError(
310
+ 'No stored topo export found for the current topo snapshot'
311
+ )
312
+ );
313
+ }
314
+
315
+ if (committedLock.hash !== currentHash) {
316
+ return Result.err(
317
+ new ConflictError(
318
+ 'trails.lock is stale. Run `trails topo export` to refresh it.'
319
+ )
320
+ );
321
+ }
322
+
323
+ return Result.ok({
324
+ committedHash: committedLock.hash,
325
+ currentHash,
326
+ lockPath: LOCK_PATH,
327
+ stale: false,
328
+ });
329
+ };
@@ -0,0 +1,228 @@
1
+ import type { Topo, Trail } from '@ontrails/core';
2
+
3
+ import { REPORT_CONTRACT_VERSION, REPORT_VERSION } from './topo-constants.js';
4
+
5
+ export interface BriefReport {
6
+ readonly name: string;
7
+ readonly version: string;
8
+ readonly contractVersion: string;
9
+ readonly features: {
10
+ readonly resources: boolean;
11
+ readonly outputSchemas: boolean;
12
+ readonly examples: boolean;
13
+ readonly detours: boolean;
14
+ readonly signals: boolean;
15
+ };
16
+ readonly trails: number;
17
+ readonly signals: number;
18
+ readonly resources: number;
19
+ }
20
+
21
+ export interface SurveyListReport {
22
+ readonly count: number;
23
+ readonly entries: readonly {
24
+ readonly examples: number;
25
+ readonly id: string;
26
+ readonly kind: string;
27
+ readonly safety: string;
28
+ }[];
29
+ readonly resourceCount: number;
30
+ readonly resources: readonly {
31
+ readonly description: string | null;
32
+ readonly health: 'available' | 'none';
33
+ readonly id: string;
34
+ readonly kind: 'resource';
35
+ readonly lifetime: 'singleton';
36
+ readonly usedBy: readonly string[];
37
+ }[];
38
+ }
39
+
40
+ export interface TrailDetailReport {
41
+ readonly description: string | null;
42
+ readonly detours:
43
+ | readonly { readonly on: string; readonly maxAttempts: number }[]
44
+ | null;
45
+ readonly examples: readonly unknown[];
46
+ readonly crosses: readonly string[];
47
+ readonly id: string;
48
+ readonly intent: 'read' | 'write' | 'destroy';
49
+ readonly kind: string;
50
+ readonly pattern: string | null;
51
+ readonly safety: string;
52
+ readonly resources: readonly string[];
53
+ }
54
+
55
+ const trailHas = (raw: Record<string, unknown>, key: string): boolean => {
56
+ if (key === 'examples' || key === 'detours') {
57
+ return Array.isArray(raw[key]) && (raw[key] as unknown[]).length > 0;
58
+ }
59
+ return Boolean(raw[key]);
60
+ };
61
+
62
+ const detectFeatures = (
63
+ app: Topo
64
+ ): {
65
+ hasDetours: boolean;
66
+ hasExamples: boolean;
67
+ hasOutputSchemas: boolean;
68
+ hasResources: boolean;
69
+ } => {
70
+ const trails = [...app.trails.values()].map(
71
+ (item) => item as unknown as Record<string, unknown>
72
+ );
73
+ return {
74
+ hasDetours: trails.some((r) => trailHas(r, 'detours')),
75
+ hasExamples: trails.some((r) => trailHas(r, 'examples')),
76
+ hasOutputSchemas: trails.some((r) => trailHas(r, 'output')),
77
+ hasResources: trails.some(
78
+ (r) =>
79
+ Array.isArray(r['resources']) &&
80
+ (r['resources'] as unknown[]).length > 0
81
+ ),
82
+ };
83
+ };
84
+
85
+ export const deriveBriefReport = (app: Topo): BriefReport => {
86
+ const { hasDetours, hasExamples, hasOutputSchemas, hasResources } =
87
+ detectFeatures(app);
88
+
89
+ return {
90
+ contractVersion: REPORT_CONTRACT_VERSION,
91
+ features: {
92
+ detours: hasDetours,
93
+ examples: hasExamples,
94
+ outputSchemas: hasOutputSchemas,
95
+ resources: hasResources,
96
+ signals: app.signals.size > 0,
97
+ },
98
+ name: app.name,
99
+ resources: app.resources.size,
100
+ signals: app.signals.size,
101
+ trails: app.trails.size,
102
+ version: REPORT_VERSION,
103
+ };
104
+ };
105
+
106
+ const safetyLabel = (entry: {
107
+ intent?: 'read' | 'write' | 'destroy';
108
+ }): string => {
109
+ if (entry.intent === 'destroy') {
110
+ return 'destroy';
111
+ }
112
+ if (entry.intent === 'write') {
113
+ return 'write';
114
+ }
115
+ if (entry.intent === 'read') {
116
+ return 'read';
117
+ }
118
+ return '-';
119
+ };
120
+
121
+ const buildResourceUsage = (
122
+ app: Topo
123
+ ): ReadonlyMap<string, readonly string[]> => {
124
+ const usage = new Map<string, string[]>();
125
+
126
+ for (const trailDef of app.list()) {
127
+ for (const declaredResource of trailDef.resources) {
128
+ const users = usage.get(declaredResource.id) ?? [];
129
+ users.push(trailDef.id);
130
+ usage.set(declaredResource.id, users);
131
+ }
132
+ }
133
+
134
+ return new Map(
135
+ [...usage.entries()].map(([id, users]) => [id, users.toSorted()] as const)
136
+ );
137
+ };
138
+
139
+ const resourceHealthStatus = (resource: {
140
+ health?: unknown;
141
+ }): 'available' | 'none' =>
142
+ resource.health === undefined ? 'none' : 'available';
143
+
144
+ export const deriveResourceDetail = (app: Topo, resourceId: string): object => {
145
+ const item = app.getResource(resourceId);
146
+ const usedBy = buildResourceUsage(app).get(resourceId) ?? [];
147
+
148
+ return {
149
+ description: item?.description ?? null,
150
+ health: item ? resourceHealthStatus(item) : 'none',
151
+ id: resourceId,
152
+ kind: 'resource',
153
+ lifetime: 'singleton',
154
+ usedBy,
155
+ };
156
+ };
157
+
158
+ const formatResourceList = (app: Topo): SurveyListReport['resources'] => {
159
+ const usage = buildResourceUsage(app);
160
+ return app
161
+ .listResources()
162
+ .map((resource) => ({
163
+ description: resource.description ?? null,
164
+ health: resourceHealthStatus(resource),
165
+ id: resource.id,
166
+ kind: resource.kind,
167
+ lifetime: 'singleton' as const,
168
+ usedBy: usage.get(resource.id) ?? [],
169
+ }))
170
+ .toSorted((a, b) => a.id.localeCompare(b.id));
171
+ };
172
+
173
+ export const deriveSurveyList = (app: Topo): SurveyListReport => {
174
+ const items = app.list();
175
+ const entries = items.map((item) => {
176
+ const safety = safetyLabel(
177
+ item as unknown as { intent?: 'read' | 'write' | 'destroy' }
178
+ );
179
+ const examples = Array.isArray(
180
+ (item as unknown as { examples?: unknown[] }).examples
181
+ )
182
+ ? (item as unknown as { examples: unknown[] }).examples.length
183
+ : 0;
184
+
185
+ return {
186
+ examples,
187
+ id: item.id,
188
+ kind: item.kind,
189
+ safety,
190
+ };
191
+ });
192
+
193
+ const resources = formatResourceList(app);
194
+
195
+ return {
196
+ count: items.length,
197
+ entries,
198
+ resourceCount: resources.length,
199
+ resources,
200
+ };
201
+ };
202
+
203
+ export const deriveTrailDetail = (
204
+ item: Trail<unknown, unknown>
205
+ ): TrailDetailReport => {
206
+ const safety = safetyLabel(
207
+ item as unknown as { intent?: 'read' | 'write' | 'destroy' }
208
+ );
209
+
210
+ return {
211
+ crosses: item.crosses.toSorted(),
212
+ description: item.description ?? null,
213
+ detours:
214
+ item.detours.length > 0
215
+ ? item.detours.map((d) => ({
216
+ maxAttempts: d.maxAttempts ?? 1,
217
+ on: d.on.name,
218
+ }))
219
+ : null,
220
+ examples: item.examples ?? [],
221
+ id: item.id,
222
+ intent: item.intent,
223
+ kind: item.kind,
224
+ pattern: item.pattern ?? null,
225
+ resources: item.resources.map((resource) => resource.id).toSorted(),
226
+ safety,
227
+ };
228
+ };
@@ -0,0 +1,54 @@
1
+ import { NotFoundError, Result, trail } from '@ontrails/core';
2
+ import { z } from 'zod';
3
+
4
+ import { loadApp } from './load-app.js';
5
+ import { buildCurrentTopoDetail } from './topo-read-support.js';
6
+
7
+ const trailDetailOutput = z.object({
8
+ crosses: z.array(z.string()),
9
+ description: z.unknown().nullable(),
10
+ detours: z.unknown().nullable(),
11
+ examples: z.array(z.unknown()),
12
+ id: z.string(),
13
+ intent: z.enum(['read', 'write', 'destroy']),
14
+ kind: z.string(),
15
+ resources: z.array(z.string()),
16
+ safety: z.string(),
17
+ });
18
+
19
+ const resourceDetailOutput = z.object({
20
+ description: z.string().nullable(),
21
+ health: z.enum(['available', 'none']),
22
+ id: z.string(),
23
+ kind: z.literal('resource'),
24
+ lifetime: z.literal('singleton'),
25
+ usedBy: z.array(z.string()),
26
+ });
27
+
28
+ export const topoShowTrail = trail('topo.show', {
29
+ blaze: async (input, ctx) => {
30
+ const rootDir = input.rootDir ?? ctx.cwd ?? process.cwd();
31
+ const app = await loadApp(input.module, rootDir);
32
+ const detail = buildCurrentTopoDetail(app, input.id, { rootDir });
33
+ if (detail !== undefined) {
34
+ return Result.ok(detail);
35
+ }
36
+ return Result.err(
37
+ new NotFoundError(`Trail or resource not found: ${input.id}`)
38
+ );
39
+ },
40
+ description: 'Show detail for a current trail or resource',
41
+ examples: [
42
+ {
43
+ input: { id: 'topo' },
44
+ name: 'Show current trail detail',
45
+ },
46
+ ],
47
+ input: z.object({
48
+ id: z.string().describe('Trail or resource ID to inspect'),
49
+ module: z.string().optional().describe('Path to the app module'),
50
+ rootDir: z.string().optional().describe('Workspace root directory'),
51
+ }),
52
+ intent: 'read',
53
+ output: z.union([trailDetailOutput, resourceDetailOutput]),
54
+ });
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Stored-export pipeline for topo persistence.
3
+ *
4
+ * Extracted from topo-support.ts to isolate store persistence concerns,
5
+ * keeping module boundaries clean.
6
+ */
7
+
8
+ import type { Topo, TopoSnapshot } from '@ontrails/core';
9
+ import { InternalError, Result } from '@ontrails/core';
10
+ import type { StoredTopoExport } from '@ontrails/core/internal/topo-store';
11
+ import {
12
+ createTopoSnapshot,
13
+ getStoredTopoExport,
14
+ } from '@ontrails/core/internal/topo-store';
15
+ import {
16
+ openWriteTrailsDb,
17
+ deriveTrailsDir,
18
+ } from '@ontrails/core/internal/trails-db';
19
+ import type { SurfaceLock, SurfaceMap } from '@ontrails/schema';
20
+ import { writeSurfaceLock, writeSurfaceMap } from '@ontrails/schema';
21
+
22
+ import type { TopoExportReport } from './topo-support.js';
23
+ import {
24
+ deriveRootDir,
25
+ deriveTopoCounts,
26
+ readGitState,
27
+ } from './topo-support.js';
28
+
29
+ const persistAndReadStoredExport = (
30
+ app: Topo,
31
+ db: ReturnType<typeof openWriteTrailsDb>,
32
+ rootDir: string
33
+ ): Result<
34
+ { snapshot: TopoSnapshot; storedExport: StoredTopoExport },
35
+ Error
36
+ > => {
37
+ const snapshotResult = createTopoSnapshot(db, app, {
38
+ ...readGitState(rootDir),
39
+ ...deriveTopoCounts(app),
40
+ });
41
+ if (snapshotResult.isErr()) {
42
+ return snapshotResult;
43
+ }
44
+
45
+ const snapshot = snapshotResult.value;
46
+ const storedExport = getStoredTopoExport(db, snapshot.id);
47
+
48
+ if (storedExport === undefined) {
49
+ return Result.err(
50
+ new InternalError(
51
+ `Missing stored topo export for snapshot "${snapshot.id}"`
52
+ )
53
+ );
54
+ }
55
+
56
+ return Result.ok({
57
+ snapshot,
58
+ storedExport,
59
+ });
60
+ };
61
+
62
+ const writeStoredExportArtifacts = async (
63
+ storedExport: StoredTopoExport,
64
+ trailsDir: string
65
+ ): Promise<Pick<TopoExportReport, 'hash' | 'lockPath' | 'mapPath'>> => {
66
+ const mapPath = await writeSurfaceMap(
67
+ JSON.parse(storedExport.surfaceMapJson) as SurfaceMap,
68
+ { dir: trailsDir }
69
+ );
70
+ const lockPath = await writeSurfaceLock(
71
+ JSON.parse(storedExport.lockContent) as SurfaceLock,
72
+ { dir: trailsDir }
73
+ );
74
+
75
+ return {
76
+ hash: storedExport.surfaceHash,
77
+ lockPath,
78
+ mapPath,
79
+ };
80
+ };
81
+
82
+ export const exportCurrentTopo = async (
83
+ app: Topo,
84
+ options?: { readonly rootDir?: string }
85
+ ): Promise<Result<TopoExportReport, Error>> => {
86
+ const rootDir = deriveRootDir(options?.rootDir);
87
+ const db = openWriteTrailsDb({ rootDir });
88
+
89
+ try {
90
+ const persisted = persistAndReadStoredExport(app, db, rootDir);
91
+ if (persisted.isErr()) {
92
+ return persisted;
93
+ }
94
+
95
+ const { snapshot, storedExport } = persisted.value;
96
+ const artifacts = await writeStoredExportArtifacts(
97
+ storedExport,
98
+ deriveTrailsDir({ rootDir })
99
+ );
100
+ return Result.ok({ ...artifacts, snapshot });
101
+ } finally {
102
+ db.close();
103
+ }
104
+ };