@ontrails/trails 1.0.0-beta.1 → 1.0.0-beta.11

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 (42) hide show
  1. package/.turbo/turbo-lint.log +1 -1
  2. package/CHANGELOG.md +154 -0
  3. package/bin/trails.ts +0 -0
  4. package/dist/src/clack.d.ts.map +1 -1
  5. package/dist/src/clack.js +22 -0
  6. package/dist/src/clack.js.map +1 -1
  7. package/dist/src/trails/add-surface.js +9 -9
  8. package/dist/src/trails/add-surface.js.map +1 -1
  9. package/dist/src/trails/add-trail.d.ts +1 -2
  10. package/dist/src/trails/add-trail.d.ts.map +1 -1
  11. package/dist/src/trails/add-trail.js +16 -24
  12. package/dist/src/trails/add-trail.js.map +1 -1
  13. package/dist/src/trails/add-verify.js +9 -9
  14. package/dist/src/trails/add-verify.js.map +1 -1
  15. package/dist/src/trails/create-scaffold.js +25 -25
  16. package/dist/src/trails/create-scaffold.js.map +1 -1
  17. package/dist/src/trails/create.d.ts +1 -1
  18. package/dist/src/trails/create.js +10 -10
  19. package/dist/src/trails/create.js.map +1 -1
  20. package/dist/src/trails/guide.js +12 -12
  21. package/dist/src/trails/guide.js.map +1 -1
  22. package/dist/src/trails/survey.d.ts +41 -2
  23. package/dist/src/trails/survey.d.ts.map +1 -1
  24. package/dist/src/trails/survey.js +141 -33
  25. package/dist/src/trails/survey.js.map +1 -1
  26. package/dist/src/trails/warden.d.ts +1 -1
  27. package/dist/src/trails/warden.js +28 -28
  28. package/dist/src/trails/warden.js.map +1 -1
  29. package/package.json +9 -9
  30. package/src/__tests__/create.test.ts +7 -7
  31. package/src/__tests__/guide.test.ts +4 -4
  32. package/src/__tests__/survey.test.ts +69 -14
  33. package/src/__tests__/warden.test.ts +2 -2
  34. package/src/clack.ts +22 -0
  35. package/src/trails/add-surface.ts +9 -9
  36. package/src/trails/add-trail.ts +16 -25
  37. package/src/trails/add-verify.ts +9 -9
  38. package/src/trails/create-scaffold.ts +27 -27
  39. package/src/trails/create.ts +10 -10
  40. package/src/trails/guide.ts +14 -14
  41. package/src/trails/survey.ts +232 -44
  42. package/src/trails/warden.ts +33 -33
@@ -10,6 +10,7 @@ import { Result, trail } from '@ontrails/core';
10
10
  import type { DiffResult } from '@ontrails/schema';
11
11
  import {
12
12
  diffSurfaceMaps,
13
+ generateOpenApiSpec,
13
14
  generateSurfaceMap,
14
15
  hashSurfaceMap,
15
16
  readSurfaceMap,
@@ -29,15 +30,46 @@ export interface BriefReport {
29
30
  readonly version: string;
30
31
  readonly contractVersion: string;
31
32
  readonly features: {
33
+ readonly services: boolean;
32
34
  readonly outputSchemas: boolean;
33
35
  readonly examples: boolean;
34
36
  readonly detours: boolean;
35
- readonly hikes: boolean;
36
37
  readonly events: boolean;
37
38
  };
38
39
  readonly trails: number;
39
- readonly hikes: number;
40
40
  readonly events: number;
41
+ readonly services: 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 serviceCount: number;
53
+ readonly services: readonly {
54
+ readonly description: string | null;
55
+ readonly health: 'available' | 'none';
56
+ readonly id: string;
57
+ readonly kind: 'service';
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 follow: readonly string[];
68
+ readonly id: string;
69
+ readonly intent: 'read' | 'write' | 'destroy';
70
+ readonly kind: string;
71
+ readonly safety: string;
72
+ readonly services: readonly string[];
41
73
  }
42
74
 
43
75
  /** Check if a trail has a specific feature. */
@@ -51,7 +83,12 @@ const trailHas = (raw: Record<string, unknown>, key: string): boolean => {
51
83
  /** Detect which features are used across trails. */
52
84
  const detectFeatures = (
53
85
  app: Topo
54
- ): { hasDetours: boolean; hasExamples: boolean; hasOutputSchemas: boolean } => {
86
+ ): {
87
+ hasDetours: boolean;
88
+ hasExamples: boolean;
89
+ hasOutputSchemas: boolean;
90
+ hasServices: boolean;
91
+ } => {
55
92
  const trails = [...app.trails.values()].map(
56
93
  (item) => item as unknown as Record<string, unknown>
57
94
  );
@@ -59,12 +96,17 @@ const detectFeatures = (
59
96
  hasDetours: trails.some((r) => trailHas(r, 'detours')),
60
97
  hasExamples: trails.some((r) => trailHas(r, 'examples')),
61
98
  hasOutputSchemas: trails.some((r) => trailHas(r, 'output')),
99
+ hasServices: trails.some(
100
+ (r) =>
101
+ Array.isArray(r['services']) && (r['services'] as unknown[]).length > 0
102
+ ),
62
103
  };
63
104
  };
64
105
 
65
106
  /** Generate a compact capability report for the given topo. */
66
107
  export const generateBriefReport = (app: Topo): BriefReport => {
67
- const { hasDetours, hasExamples, hasOutputSchemas } = detectFeatures(app);
108
+ const { hasDetours, hasExamples, hasOutputSchemas, hasServices } =
109
+ detectFeatures(app);
68
110
 
69
111
  return {
70
112
  contractVersion: '2026-03',
@@ -73,11 +115,11 @@ export const generateBriefReport = (app: Topo): BriefReport => {
73
115
  detours: hasDetours,
74
116
  events: app.events.size > 0,
75
117
  examples: hasExamples,
76
- hikes: app.hikes.size > 0,
77
118
  outputSchemas: hasOutputSchemas,
119
+ services: hasServices,
78
120
  },
79
- hikes: app.hikes.size,
80
121
  name: app.name,
122
+ services: app.services.size,
81
123
  trails: app.trails.size,
82
124
  version: '0.1.0',
83
125
  };
@@ -88,23 +130,60 @@ export const generateBriefReport = (app: Topo): BriefReport => {
88
130
  // ---------------------------------------------------------------------------
89
131
 
90
132
  const safetyLabel = (entry: {
91
- readOnly?: boolean;
92
- destructive?: boolean;
133
+ intent?: 'read' | 'write' | 'destroy';
93
134
  }): string => {
94
- if (entry.destructive) {
95
- return 'destructive';
135
+ if (entry.intent === 'destroy') {
136
+ return 'destroy';
96
137
  }
97
- if (entry.readOnly) {
98
- return 'readOnly';
138
+ if (entry.intent === 'read') {
139
+ return 'read';
99
140
  }
100
141
  return '-';
101
142
  };
102
143
 
103
- const formatTrailList = (app: Topo): object => {
144
+ const buildServiceUsage = (
145
+ app: Topo
146
+ ): ReadonlyMap<string, readonly string[]> => {
147
+ const usage = new Map<string, string[]>();
148
+
149
+ for (const trailDef of app.list()) {
150
+ for (const declaredService of trailDef.services) {
151
+ const users = usage.get(declaredService.id) ?? [];
152
+ users.push(trailDef.id);
153
+ usage.set(declaredService.id, users);
154
+ }
155
+ }
156
+
157
+ return new Map(
158
+ [...usage.entries()].map(([id, users]) => [id, users.toSorted()] as const)
159
+ );
160
+ };
161
+
162
+ const serviceHealthStatus = (service: {
163
+ health?: unknown;
164
+ }): 'available' | 'none' =>
165
+ service.health === undefined ? 'none' : 'available';
166
+
167
+ const formatServiceList = (app: Topo): SurveyListReport['services'] => {
168
+ const usage = buildServiceUsage(app);
169
+ return app
170
+ .listServices()
171
+ .map((service) => ({
172
+ description: service.description ?? null,
173
+ health: serviceHealthStatus(service),
174
+ id: service.id,
175
+ kind: service.kind,
176
+ lifetime: 'singleton' as const,
177
+ usedBy: usage.get(service.id) ?? [],
178
+ }))
179
+ .toSorted((a, b) => a.id.localeCompare(b.id));
180
+ };
181
+
182
+ export const generateSurveyList = (app: Topo): SurveyListReport => {
104
183
  const items = app.list();
105
184
  const entries = items.map((item) => {
106
185
  const safety = safetyLabel(
107
- item as unknown as { readOnly?: boolean; destructive?: boolean }
186
+ item as unknown as { intent?: 'read' | 'write' | 'destroy' }
108
187
  );
109
188
  const examples = Array.isArray(
110
189
  (item as unknown as { examples?: unknown[] }).examples
@@ -120,7 +199,14 @@ const formatTrailList = (app: Topo): object => {
120
199
  };
121
200
  });
122
201
 
123
- return { count: items.length, entries };
202
+ const services = formatServiceList(app);
203
+
204
+ return {
205
+ count: items.length,
206
+ entries,
207
+ serviceCount: services.length,
208
+ services,
209
+ };
124
210
  };
125
211
 
126
212
  /**
@@ -130,18 +216,37 @@ const formatTrailList = (app: Topo): object => {
130
216
  * surface-map entry. The two serve different audiences (human display vs
131
217
  * machine-diffable surface map) so they are kept separate.
132
218
  */
133
- const formatTrailDetail = (item: Trail<unknown, unknown>): object => {
219
+ export const generateTrailDetail = (
220
+ item: Trail<unknown, unknown>
221
+ ): TrailDetailReport => {
134
222
  const safety = safetyLabel(
135
- item as unknown as { readOnly?: boolean; destructive?: boolean }
223
+ item as unknown as { intent?: 'read' | 'write' | 'destroy' }
136
224
  );
137
225
 
138
226
  return {
139
227
  description: item.description ?? null,
140
228
  detours: item.detours ?? null,
141
229
  examples: item.examples ?? [],
230
+ follow: item.follow.toSorted(),
142
231
  id: item.id,
232
+ intent: item.intent,
143
233
  kind: item.kind,
144
234
  safety,
235
+ services: item.services.map((service) => service.id).toSorted(),
236
+ };
237
+ };
238
+
239
+ const formatServiceDetail = (app: Topo, serviceId: string): object => {
240
+ const item = app.getService(serviceId);
241
+ const usedBy = buildServiceUsage(app).get(serviceId) ?? [];
242
+
243
+ return {
244
+ description: item?.description ?? null,
245
+ health: item ? serviceHealthStatus(item) : 'none',
246
+ id: serviceId,
247
+ kind: 'service',
248
+ lifetime: 'singleton',
249
+ usedBy,
145
250
  };
146
251
  };
147
252
 
@@ -184,10 +289,13 @@ const buildSurveyDetail = (
184
289
  trailId: string
185
290
  ): Result<object, Error> => {
186
291
  const item = app.get(trailId);
187
- if (!item) {
188
- return Result.err(new Error(`Trail not found: ${trailId}`));
292
+ if (item) {
293
+ return Result.ok(generateTrailDetail(item as Trail<unknown, unknown>));
294
+ }
295
+ if (app.getService(trailId)) {
296
+ return Result.ok(formatServiceDetail(app, trailId));
189
297
  }
190
- return Result.ok(formatTrailDetail(item as Trail<unknown, unknown>));
298
+ return Result.err(new Error(`Trail or service not found: ${trailId}`));
191
299
  };
192
300
 
193
301
  const buildSurveyGenerate = async (
@@ -200,6 +308,55 @@ const buildSurveyGenerate = async (
200
308
  return Result.ok({ hash, lockPath, mapPath });
201
309
  };
202
310
 
311
+ interface SurveyInput {
312
+ breakingOnly: boolean;
313
+ brief: boolean;
314
+ diff?: string | undefined;
315
+ generate: boolean;
316
+ openapi: boolean;
317
+ trailId?: string | undefined;
318
+ }
319
+
320
+ type SurveyMode = 'brief' | 'detail' | 'diff' | 'generate' | 'list' | 'openapi';
321
+
322
+ /** Ordered mode checks — first truthy predicate wins, otherwise 'list'. */
323
+ const modeChecks: readonly [(input: SurveyInput) => boolean, SurveyMode][] = [
324
+ [(i) => i.brief, 'brief'],
325
+ [(i) => Boolean(i.diff), 'diff'],
326
+ [(i) => Boolean(i.trailId), 'detail'],
327
+ [(i) => i.generate, 'generate'],
328
+ [(i) => i.openapi, 'openapi'],
329
+ ];
330
+
331
+ /** Determine which survey mode was requested, falling back to 'list'. */
332
+ const resolveSurveyMode = (input: SurveyInput): SurveyMode =>
333
+ modeChecks.find(([predicate]) => predicate(input))?.[1] ?? 'list';
334
+
335
+ type SurveyHandler = (
336
+ app: Topo,
337
+ input: SurveyInput
338
+ ) => Result<object, Error> | Promise<Result<object, Error>>;
339
+
340
+ /** Handlers keyed by survey mode. */
341
+ const surveyHandlers: Record<SurveyMode, SurveyHandler> = {
342
+ brief: (app) => Result.ok(generateBriefReport(app)),
343
+ detail: (app, input) => buildSurveyDetail(app, input.trailId ?? ''),
344
+ diff: (app, input) => buildSurveyDiff(app, input.breakingOnly),
345
+ generate: (app) => buildSurveyGenerate(app),
346
+ list: (app) => Result.ok(generateSurveyList(app)),
347
+ openapi: (app) => Result.ok(generateOpenApiSpec(app)),
348
+ };
349
+
350
+ /** Dispatch to the appropriate survey sub-command based on input flags. */
351
+ const dispatchSurvey = (
352
+ app: Topo,
353
+ input: SurveyInput
354
+ ): Result<object, Error> | Promise<Result<object, Error>> => {
355
+ const mode = resolveSurveyMode(input);
356
+ const handler = surveyHandlers[mode];
357
+ return handler(app, input);
358
+ };
359
+
203
360
  // ---------------------------------------------------------------------------
204
361
  // Trail definition
205
362
  // ---------------------------------------------------------------------------
@@ -217,28 +374,12 @@ export const surveyTrail = trail('survey', {
217
374
  input: { brief: true },
218
375
  name: 'Brief capability report',
219
376
  },
377
+ {
378
+ description: 'Generate an OpenAPI 3.1 specification for the topo',
379
+ input: { openapi: true },
380
+ name: 'OpenAPI spec',
381
+ },
220
382
  ],
221
- implementation: async (input, ctx) => {
222
- const app = await loadApp(input.module, ctx.cwd ?? '.');
223
-
224
- if (input.brief) {
225
- return Result.ok(generateBriefReport(app));
226
- }
227
-
228
- if (input.diff) {
229
- return await buildSurveyDiff(app, input.breakingOnly);
230
- }
231
-
232
- if (input.trailId) {
233
- return buildSurveyDetail(app, input.trailId);
234
- }
235
-
236
- if (input.generate) {
237
- return await buildSurveyGenerate(app);
238
- }
239
-
240
- return Result.ok(formatTrailList(app));
241
- },
242
383
  input: z.object({
243
384
  breakingOnly: z
244
385
  .boolean()
@@ -254,8 +395,10 @@ export const surveyTrail = trail('survey', {
254
395
  .string()
255
396
  .default('./src/app.ts')
256
397
  .describe('Path to the app module'),
398
+ openapi: z.boolean().default(false).describe('Output OpenAPI 3.1 spec'),
257
399
  trailId: z.string().optional().describe('Trail ID for detail view'),
258
400
  }),
401
+ intent: 'read',
259
402
  output: z.union([
260
403
  z.object({
261
404
  count: z.number(),
@@ -267,6 +410,17 @@ export const surveyTrail = trail('survey', {
267
410
  safety: z.string(),
268
411
  })
269
412
  ),
413
+ serviceCount: z.number(),
414
+ services: z.array(
415
+ z.object({
416
+ description: z.string().nullable(),
417
+ health: z.enum(['available', 'none']),
418
+ id: z.string(),
419
+ kind: z.literal('service'),
420
+ lifetime: z.literal('singleton'),
421
+ usedBy: z.array(z.string()),
422
+ })
423
+ ),
270
424
  }),
271
425
  z.object({
272
426
  contractVersion: z.string(),
@@ -275,11 +429,11 @@ export const surveyTrail = trail('survey', {
275
429
  detours: z.boolean(),
276
430
  events: z.boolean(),
277
431
  examples: z.boolean(),
278
- hikes: z.boolean(),
279
432
  outputSchemas: z.boolean(),
433
+ services: z.boolean(),
280
434
  }),
281
- hikes: z.number(),
282
435
  name: z.string(),
436
+ services: z.number(),
283
437
  trails: z.number(),
284
438
  version: z.string(),
285
439
  }),
@@ -293,15 +447,49 @@ export const surveyTrail = trail('survey', {
293
447
  description: z.unknown().nullable(),
294
448
  detours: z.unknown().nullable(),
295
449
  examples: z.array(z.unknown()),
450
+ follow: z.array(z.string()),
296
451
  id: z.string(),
452
+ intent: z.enum(['read', 'write', 'destroy']),
297
453
  kind: z.string(),
298
454
  safety: z.string(),
455
+ services: z.array(z.string()),
456
+ }),
457
+ z.object({
458
+ description: z.string().nullable(),
459
+ health: z.enum(['available', 'none']),
460
+ id: z.string(),
461
+ kind: z.literal('service'),
462
+ lifetime: z.literal('singleton'),
463
+ usedBy: z.array(z.string()),
299
464
  }),
300
465
  z.object({
301
466
  hash: z.string(),
302
467
  lockPath: z.string(),
303
468
  mapPath: z.string(),
304
469
  }),
470
+ z.object({
471
+ components: z.object({
472
+ schemas: z.record(z.string(), z.unknown()),
473
+ }),
474
+ info: z.object({
475
+ description: z.string().optional(),
476
+ title: z.string(),
477
+ version: z.string(),
478
+ }),
479
+ openapi: z.literal('3.1.0'),
480
+ paths: z.record(z.string(), z.record(z.string(), z.unknown())),
481
+ servers: z
482
+ .array(
483
+ z.object({
484
+ description: z.string().optional(),
485
+ url: z.string(),
486
+ })
487
+ )
488
+ .optional(),
489
+ }),
305
490
  ]),
306
- readOnly: true,
491
+ run: async (input, ctx) => {
492
+ const app = await loadApp(input.module, ctx.cwd ?? '.');
493
+ return dispatchSurvey(app, input);
494
+ },
307
495
  });
@@ -37,38 +37,6 @@ export const wardenTrail = trail('warden', {
37
37
  name: 'GitHub Actions annotations',
38
38
  },
39
39
  ],
40
- implementation: async (input, ctx) => {
41
- const rootDir = input.rootDir ?? ctx.cwd ?? process.cwd();
42
- // oxlint-disable-next-line prefer-await-to-then -- catch converts rejection to undefined cleanly
43
- const topo = await loadApp('./src/app.ts', rootDir).catch(
44
- (): undefined => undefined
45
- );
46
-
47
- const report = await runWarden({
48
- driftOnly: input.driftOnly,
49
- lintOnly: input.lintOnly,
50
- rootDir,
51
- topo,
52
- });
53
-
54
- const formatters: Record<string, (r: typeof report) => string> = {
55
- github: formatGitHubAnnotations,
56
- json: formatJson,
57
- summary: formatSummary,
58
- text: formatWardenReport,
59
- };
60
- const formatter = formatters[input.format] ?? formatWardenReport;
61
- const formatted = formatter(report);
62
-
63
- return Result.ok({
64
- diagnostics: report.diagnostics,
65
- drift: report.drift,
66
- errorCount: report.errorCount,
67
- formatted,
68
- passed: report.passed,
69
- warnCount: report.warnCount,
70
- });
71
- },
72
40
  input: z.object({
73
41
  driftOnly: z.boolean().default(false).describe('Only run drift detection'),
74
42
  format: z
@@ -78,6 +46,7 @@ export const wardenTrail = trail('warden', {
78
46
  lintOnly: z.boolean().default(false).describe('Only run lint rules'),
79
47
  rootDir: z.string().optional().describe('Root directory to scan'),
80
48
  }),
49
+ intent: 'read',
81
50
  output: z.object({
82
51
  diagnostics: z.array(
83
52
  z.object({
@@ -100,5 +69,36 @@ export const wardenTrail = trail('warden', {
100
69
  passed: z.boolean(),
101
70
  warnCount: z.number(),
102
71
  }),
103
- readOnly: true,
72
+ run: async (input, ctx) => {
73
+ const rootDir = input.rootDir ?? ctx.cwd ?? process.cwd();
74
+ // oxlint-disable-next-line prefer-await-to-then -- catch converts rejection to undefined cleanly
75
+ const topo = await loadApp('./src/app.ts', rootDir).catch(
76
+ (): undefined => undefined
77
+ );
78
+
79
+ const report = await runWarden({
80
+ driftOnly: input.driftOnly,
81
+ lintOnly: input.lintOnly,
82
+ rootDir,
83
+ topo,
84
+ });
85
+
86
+ const formatters: Record<string, (r: typeof report) => string> = {
87
+ github: formatGitHubAnnotations,
88
+ json: formatJson,
89
+ summary: formatSummary,
90
+ text: formatWardenReport,
91
+ };
92
+ const formatter = formatters[input.format] ?? formatWardenReport;
93
+ const formatted = formatter(report);
94
+
95
+ return Result.ok({
96
+ diagnostics: report.diagnostics,
97
+ drift: report.drift,
98
+ errorCount: report.errorCount,
99
+ formatted,
100
+ passed: report.passed,
101
+ warnCount: report.warnCount,
102
+ });
103
+ },
104
104
  });