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

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 (140) hide show
  1. package/.turbo/turbo-lint.log +1 -1
  2. package/CHANGELOG.md +16 -0
  3. package/__tests__/examples.test.ts +14 -0
  4. package/dist/src/app.d.ts.map +1 -1
  5. package/dist/src/app.js +13 -2
  6. package/dist/src/app.js.map +1 -1
  7. package/dist/src/clack.d.ts +1 -1
  8. package/dist/src/clack.js +1 -1
  9. package/dist/src/cli.js +2 -2
  10. package/dist/src/cli.js.map +1 -1
  11. package/dist/src/trails/add-trail.js +13 -13
  12. package/dist/src/trails/add-trail.js.map +1 -1
  13. package/dist/src/trails/add-trailhead.d.ts +13 -0
  14. package/dist/src/trails/add-trailhead.d.ts.map +1 -0
  15. package/dist/src/trails/add-trailhead.js +88 -0
  16. package/dist/src/trails/add-trailhead.js.map +1 -0
  17. package/dist/src/trails/add-verify.js +10 -10
  18. package/dist/src/trails/add-verify.js.map +1 -1
  19. package/dist/src/trails/create-scaffold.js +26 -26
  20. package/dist/src/trails/create-scaffold.js.map +1 -1
  21. package/dist/src/trails/create.d.ts +6 -6
  22. package/dist/src/trails/create.d.ts.map +1 -1
  23. package/dist/src/trails/create.js +29 -29
  24. package/dist/src/trails/create.js.map +1 -1
  25. package/dist/src/trails/dev-clean.d.ts +9 -0
  26. package/dist/src/trails/dev-clean.d.ts.map +1 -0
  27. package/dist/src/trails/dev-clean.js +65 -0
  28. package/dist/src/trails/dev-clean.js.map +1 -0
  29. package/dist/src/trails/dev-reset.d.ts +6 -0
  30. package/dist/src/trails/dev-reset.d.ts.map +1 -0
  31. package/dist/src/trails/dev-reset.js +38 -0
  32. package/dist/src/trails/dev-reset.js.map +1 -0
  33. package/dist/src/trails/dev-stats.d.ts +7 -0
  34. package/dist/src/trails/dev-stats.d.ts.map +1 -0
  35. package/dist/src/trails/dev-stats.js +61 -0
  36. package/dist/src/trails/dev-stats.js.map +1 -0
  37. package/dist/src/trails/dev-support.d.ts +64 -0
  38. package/dist/src/trails/dev-support.d.ts.map +1 -0
  39. package/dist/src/trails/dev-support.js +178 -0
  40. package/dist/src/trails/dev-support.js.map +1 -0
  41. package/dist/src/trails/draft-promote.d.ts +18 -0
  42. package/dist/src/trails/draft-promote.d.ts.map +1 -0
  43. package/dist/src/trails/draft-promote.js +386 -0
  44. package/dist/src/trails/draft-promote.js.map +1 -0
  45. package/dist/src/trails/guide.d.ts +13 -3
  46. package/dist/src/trails/guide.d.ts.map +1 -1
  47. package/dist/src/trails/guide.js +21 -37
  48. package/dist/src/trails/guide.js.map +1 -1
  49. package/dist/src/trails/load-app.d.ts +3 -1
  50. package/dist/src/trails/load-app.d.ts.map +1 -1
  51. package/dist/src/trails/load-app.js +53 -10
  52. package/dist/src/trails/load-app.js.map +1 -1
  53. package/dist/src/trails/project.d.ts.map +1 -1
  54. package/dist/src/trails/project.js +14 -3
  55. package/dist/src/trails/project.js.map +1 -1
  56. package/dist/src/trails/survey.d.ts +4 -58
  57. package/dist/src/trails/survey.d.ts.map +1 -1
  58. package/dist/src/trails/survey.js +52 -173
  59. package/dist/src/trails/survey.js.map +1 -1
  60. package/dist/src/trails/topo-constants.d.ts +3 -0
  61. package/dist/src/trails/topo-constants.d.ts.map +1 -0
  62. package/dist/src/trails/topo-constants.js +3 -0
  63. package/dist/src/trails/topo-constants.js.map +1 -0
  64. package/dist/src/trails/topo-export.d.ts +18 -0
  65. package/dist/src/trails/topo-export.d.ts.map +1 -0
  66. package/dist/src/trails/topo-export.js +34 -0
  67. package/dist/src/trails/topo-export.js.map +1 -0
  68. package/dist/src/trails/topo-history.d.ts +24 -0
  69. package/dist/src/trails/topo-history.d.ts.map +1 -0
  70. package/dist/src/trails/topo-history.js +33 -0
  71. package/dist/src/trails/topo-history.js.map +1 -0
  72. package/dist/src/trails/topo-pin.d.ts +21 -0
  73. package/dist/src/trails/topo-pin.d.ts.map +1 -0
  74. package/dist/src/trails/topo-pin.js +35 -0
  75. package/dist/src/trails/topo-pin.js.map +1 -0
  76. package/dist/src/trails/topo-read-support.d.ts +54 -0
  77. package/dist/src/trails/topo-read-support.d.ts.map +1 -0
  78. package/dist/src/trails/topo-read-support.js +178 -0
  79. package/dist/src/trails/topo-read-support.js.map +1 -0
  80. package/dist/src/trails/topo-reports.d.ts +50 -0
  81. package/dist/src/trails/topo-reports.d.ts.map +1 -0
  82. package/dist/src/trails/topo-reports.js +122 -0
  83. package/dist/src/trails/topo-reports.js.map +1 -0
  84. package/dist/src/trails/topo-show.d.ts +23 -0
  85. package/dist/src/trails/topo-show.d.ts.map +1 -0
  86. package/dist/src/trails/topo-show.js +53 -0
  87. package/dist/src/trails/topo-show.js.map +1 -0
  88. package/dist/src/trails/topo-store-support.d.ts +13 -0
  89. package/dist/src/trails/topo-store-support.d.ts.map +1 -0
  90. package/dist/src/trails/topo-store-support.js +55 -0
  91. package/dist/src/trails/topo-store-support.js.map +1 -0
  92. package/dist/src/trails/topo-support.d.ts +87 -0
  93. package/dist/src/trails/topo-support.d.ts.map +1 -0
  94. package/dist/src/trails/topo-support.js +165 -0
  95. package/dist/src/trails/topo-support.js.map +1 -0
  96. package/dist/src/trails/topo-unpin.d.ts +15 -0
  97. package/dist/src/trails/topo-unpin.d.ts.map +1 -0
  98. package/dist/src/trails/topo-unpin.js +39 -0
  99. package/dist/src/trails/topo-unpin.js.map +1 -0
  100. package/dist/src/trails/topo-verify.d.ts +5 -0
  101. package/dist/src/trails/topo-verify.d.ts.map +1 -0
  102. package/dist/src/trails/topo-verify.js +28 -0
  103. package/dist/src/trails/topo-verify.js.map +1 -0
  104. package/dist/src/trails/topo.d.ts +5 -0
  105. package/dist/src/trails/topo.d.ts.map +1 -0
  106. package/dist/src/trails/topo.js +67 -0
  107. package/dist/src/trails/topo.js.map +1 -0
  108. package/dist/src/trails/warden.d.ts +1 -1
  109. package/dist/src/trails/warden.d.ts.map +1 -1
  110. package/dist/src/trails/warden.js +28 -27
  111. package/dist/src/trails/warden.js.map +1 -1
  112. package/dist/tsconfig.tsbuildinfo +1 -1
  113. package/package.json +8 -7
  114. package/src/__tests__/draft-promote.test.ts +144 -0
  115. package/src/__tests__/load-app.test.ts +43 -0
  116. package/src/__tests__/survey.test.ts +85 -0
  117. package/src/__tests__/topo-dev.test.ts +424 -0
  118. package/src/app.ts +22 -0
  119. package/src/trails/dev-clean.ts +73 -0
  120. package/src/trails/dev-reset.ts +44 -0
  121. package/src/trails/dev-stats.ts +64 -0
  122. package/src/trails/dev-support.ts +326 -0
  123. package/src/trails/draft-promote.ts +704 -0
  124. package/src/trails/guide.ts +22 -37
  125. package/src/trails/load-app.ts +76 -13
  126. package/src/trails/project.ts +17 -3
  127. package/src/trails/survey.ts +56 -256
  128. package/src/trails/topo-constants.ts +2 -0
  129. package/src/trails/topo-export.ts +39 -0
  130. package/src/trails/topo-history.ts +40 -0
  131. package/src/trails/topo-pin.ts +42 -0
  132. package/src/trails/topo-read-support.ts +332 -0
  133. package/src/trails/topo-reports.ts +221 -0
  134. package/src/trails/topo-show.ts +58 -0
  135. package/src/trails/topo-store-support.ts +96 -0
  136. package/src/trails/topo-support.ts +274 -0
  137. package/src/trails/topo-unpin.ts +51 -0
  138. package/src/trails/topo-verify.ts +29 -0
  139. package/src/trails/topo.ts +73 -0
  140. package/src/trails/warden.ts +1 -0
@@ -4,11 +4,14 @@
4
4
  * Lists trails with descriptions and examples. Detailed guidance is planned for post-v1.
5
5
  */
6
6
 
7
- import type { Topo, Trail } from '@ontrails/core';
8
- import { Result, trail } from '@ontrails/core';
7
+ import { NotFoundError, Result, trail } from '@ontrails/core';
9
8
  import { z } from 'zod';
10
9
 
11
10
  import { loadApp } from './load-app.js';
11
+ import {
12
+ buildCurrentGuideEntries,
13
+ buildCurrentTopoDetail,
14
+ } from './topo-read-support.js';
12
15
 
13
16
  // ---------------------------------------------------------------------------
14
17
  // Types
@@ -25,48 +28,30 @@ interface GuideEntry {
25
28
  // Helpers
26
29
  // ---------------------------------------------------------------------------
27
30
 
28
- const toGuideEntries = (app: Topo): GuideEntry[] => {
29
- const entries: GuideEntry[] = [];
30
-
31
- for (const item of app.list()) {
32
- const raw = item as unknown as Record<string, unknown>;
33
- entries.push({
34
- description:
35
- typeof raw['description'] === 'string'
36
- ? raw['description']
37
- : '(no description)',
38
- exampleCount: Array.isArray(raw['examples'])
39
- ? (raw['examples'] as unknown[]).length
40
- : 0,
41
- id: item.id,
42
- kind: item.kind,
43
- });
44
- }
45
-
46
- return entries;
47
- };
48
-
49
- const toGuideDetail = (item: Trail<unknown, unknown>): object => ({
50
- description: item.description ?? null,
51
- detours: item.detours ?? null,
52
- examples: item.examples ?? [],
53
- id: item.id,
54
- kind: item.kind,
55
- });
56
-
57
31
  export const guideTrail = trail('guide', {
58
32
  blaze: async (input, ctx) => {
59
- const app = await loadApp(input.module, ctx.cwd ?? '.');
33
+ const rootDir = ctx.cwd ?? '.';
34
+ const app = await loadApp(input.module, rootDir);
60
35
 
61
36
  if (input.trailId) {
62
- const item = app.get(input.trailId);
63
- if (!item) {
64
- return Result.err(new Error(`Trail not found: ${input.trailId}`));
37
+ const detail = buildCurrentTopoDetail(app, input.trailId, { rootDir });
38
+ if (detail === undefined || detail.kind !== 'trail') {
39
+ return Result.err(
40
+ new NotFoundError(`Trail not found: ${input.trailId}`)
41
+ );
65
42
  }
66
- return Result.ok(toGuideDetail(item as Trail<unknown, unknown>));
43
+ return Result.ok({
44
+ description: detail.description,
45
+ detours: detail.detours,
46
+ examples: detail.examples,
47
+ id: detail.id,
48
+ kind: detail.kind,
49
+ });
67
50
  }
68
51
 
69
- return Result.ok(toGuideEntries(app));
52
+ return Result.ok(
53
+ buildCurrentGuideEntries(app, { rootDir }) as GuideEntry[]
54
+ );
70
55
  },
71
56
  description: 'Runtime guidance for trails',
72
57
  examples: [
@@ -1,31 +1,94 @@
1
- import { isAbsolute, resolve } from 'node:path';
2
- import { pathToFileURL } from 'node:url';
1
+ import { existsSync, rmSync } from 'node:fs';
2
+ import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
3
+ import { fileURLToPath, pathToFileURL } from 'node:url';
3
4
 
4
5
  import type { Topo } from '@ontrails/core';
5
6
 
6
7
  const URL_SCHEME = /^[a-zA-Z][a-zA-Z\d+.-]*:/;
7
8
 
8
- /** Resolve a module path from cwd so CLI defaults behave like shell paths. */
9
- const resolveModuleSpecifier = (modulePath: string, cwd: string): string => {
10
- if (URL_SCHEME.test(modulePath)) {
11
- return modulePath;
12
- }
9
+ const resolveUrlModulePath = (modulePath: string): string => {
10
+ const url = new URL(modulePath);
11
+ return url.protocol === 'file:' ? fileURLToPath(url) : modulePath;
12
+ };
13
13
 
14
+ const resolveFilesystemModulePath = (
15
+ modulePath: string,
16
+ cwd: string
17
+ ): string => {
14
18
  const absolutePath = isAbsolute(modulePath)
15
19
  ? modulePath
16
20
  : resolve(cwd, modulePath);
17
- return pathToFileURL(absolutePath).href;
21
+ if (!absolutePath.endsWith('.js') || existsSync(absolutePath)) {
22
+ return absolutePath;
23
+ }
24
+
25
+ const tsPath = absolutePath.replace(/\.js$/, '.ts');
26
+ return existsSync(tsPath) ? tsPath : absolutePath;
27
+ };
28
+
29
+ /** Resolve a module path from cwd so CLI defaults behave like shell paths. */
30
+ const resolveAbsoluteModulePath = (modulePath: string, cwd: string): string =>
31
+ URL_SCHEME.test(modulePath)
32
+ ? resolveUrlModulePath(modulePath)
33
+ : resolveFilesystemModulePath(modulePath, cwd);
34
+
35
+ const freshModuleCopyPath = (absolutePath: string): string =>
36
+ join(
37
+ dirname(absolutePath),
38
+ `.__fresh-${Date.now()}-${Math.random().toString(36).slice(2)}-${basename(absolutePath)}`
39
+ );
40
+
41
+ /**
42
+ * Import a module bypassing the ESM cache for the entry file.
43
+ *
44
+ * @remarks
45
+ * Cache-busting applies to the entry module only. Transitive imports resolved
46
+ * by the entry file are still served from Bun's module cache. This is
47
+ * acceptable for the draft promotion workflow (the only caller) because
48
+ * promotion changes which modules the entry file imports, not the modules
49
+ * themselves. If a deeper cache-bust is needed in the future, consider
50
+ * Bun's `Loader.registry` or a full process restart.
51
+ */
52
+ const importFreshModule = async (
53
+ modulePath: string,
54
+ cwd: string
55
+ ): Promise<Record<string, unknown>> => {
56
+ const absolutePath = resolveAbsoluteModulePath(modulePath, cwd);
57
+ if (URL_SCHEME.test(absolutePath) && !absolutePath.startsWith('/')) {
58
+ const url = new URL(absolutePath);
59
+ url.searchParams.set('t', Date.now().toString());
60
+ return (await import(url.href)) as Record<string, unknown>;
61
+ }
62
+
63
+ const freshPath = freshModuleCopyPath(absolutePath);
64
+ await Bun.write(freshPath, await Bun.file(absolutePath).text());
65
+
66
+ try {
67
+ return (await import(pathToFileURL(freshPath).href)) as Record<
68
+ string,
69
+ unknown
70
+ >;
71
+ } finally {
72
+ rmSync(freshPath, { force: true });
73
+ }
18
74
  };
19
75
 
20
76
  /** Load a Topo export from a module path relative to cwd. */
21
77
  export const loadApp = async (
22
78
  modulePath: string,
23
- cwd: string
79
+ cwd: string,
80
+ options: { fresh?: boolean | undefined } = {}
24
81
  ): Promise<Topo> => {
25
- const mod = (await import(resolveModuleSpecifier(modulePath, cwd))) as Record<
26
- string,
27
- unknown
28
- >;
82
+ const resolvedModulePath = resolveAbsoluteModulePath(modulePath, cwd);
83
+ const mod =
84
+ options.fresh === true
85
+ ? await importFreshModule(modulePath, cwd)
86
+ : ((await import(
87
+ URL_SCHEME.test(resolvedModulePath) &&
88
+ !resolvedModulePath.startsWith('/')
89
+ ? new URL(resolvedModulePath).href
90
+ : pathToFileURL(resolvedModulePath).href
91
+ )) as Record<string, unknown>);
29
92
  const app = (mod['default'] ?? mod['app']) as Topo | undefined;
30
93
  if (!app?.trails) {
31
94
  throw new Error(
@@ -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 (
@@ -5,252 +5,41 @@
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 type { Topo } from '@ontrails/core';
9
+ import { NotFoundError, Result, trail } from '@ontrails/core';
10
10
  import type { DiffResult } from '@ontrails/schema';
11
11
  import {
12
12
  diffTrailheadMaps,
13
13
  generateOpenApiSpec,
14
14
  generateTrailheadMap,
15
- hashTrailheadMap,
16
15
  readTrailheadMap,
17
- writeTrailheadLock,
18
- writeTrailheadMap,
19
16
  } from '@ontrails/schema';
20
17
  import { z } from 'zod';
21
18
 
22
19
  import { loadApp } from './load-app.js';
20
+ import {
21
+ buildCurrentTopoBrief,
22
+ buildCurrentTopoDetail,
23
+ buildCurrentTopoList,
24
+ } from './topo-read-support.js';
25
+ import { exportCurrentTopo } from './topo-store-support.js';
26
+
27
+ export {
28
+ formatProvisionDetail,
29
+ generateBriefReport,
30
+ generateSurveyList,
31
+ generateTrailDetail,
32
+ } from './topo-reports.js';
33
+ export type {
34
+ BriefReport,
35
+ SurveyListReport,
36
+ TrailDetailReport,
37
+ } from './topo-reports.js';
23
38
 
24
39
  // ---------------------------------------------------------------------------
25
40
  // Brief report (formerly scout)
26
41
  // ---------------------------------------------------------------------------
27
42
 
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
43
  const formatDiff = (diff: DiffResult): object => ({
255
44
  breaking: diff.breaking,
256
45
  hasBreaking: diff.hasBreaking,
@@ -266,8 +55,8 @@ const buildSurveyDiff = async (
266
55
  const previousMap = await readTrailheadMap();
267
56
  if (!previousMap) {
268
57
  return Result.err(
269
- new Error(
270
- 'No previous trailhead map found. Run `trails survey generate` first.'
58
+ new NotFoundError(
59
+ 'No previous trailhead map found. Run `trails topo export` first.'
271
60
  )
272
61
  );
273
62
  }
@@ -287,26 +76,31 @@ const buildSurveyDiff = async (
287
76
 
288
77
  const buildSurveyDetail = (
289
78
  app: Topo,
290
- trailId: string
79
+ trailId: string,
80
+ rootDir: string
291
81
  ): Result<object, Error> => {
292
- const item = app.get(trailId);
293
- if (item) {
294
- return Result.ok(generateTrailDetail(item as Trail<unknown, unknown>));
295
- }
296
- if (app.getProvision(trailId)) {
297
- return Result.ok(formatProvisionDetail(app, trailId));
82
+ const detail = buildCurrentTopoDetail(app, trailId, { rootDir });
83
+ if (detail !== undefined) {
84
+ return Result.ok(detail);
298
85
  }
299
- return Result.err(new Error(`Trail or provision not found: ${trailId}`));
86
+ return Result.err(
87
+ new NotFoundError(`Trail or provision not found: ${trailId}`)
88
+ );
300
89
  };
301
90
 
302
91
  const buildSurveyGenerate = async (
303
- app: Topo
92
+ app: Topo,
93
+ rootDir: string
304
94
  ): 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 });
95
+ const exported = await exportCurrentTopo(app, { rootDir });
96
+ if (exported.isErr()) {
97
+ return exported;
98
+ }
99
+ return Result.ok({
100
+ hash: exported.value.hash,
101
+ lockPath: exported.value.lockPath,
102
+ mapPath: exported.value.mapPath,
103
+ });
310
104
  };
311
105
 
312
106
  interface SurveyInput {
@@ -335,27 +129,32 @@ const resolveSurveyMode = (input: SurveyInput): SurveyMode =>
335
129
 
336
130
  type SurveyHandler = (
337
131
  app: Topo,
338
- input: SurveyInput
132
+ input: SurveyInput,
133
+ rootDir: string
339
134
  ) => Result<object, Error> | Promise<Result<object, Error>>;
340
135
 
341
136
  /** Handlers keyed by survey mode. */
342
137
  const surveyHandlers: Record<SurveyMode, SurveyHandler> = {
343
- brief: (app) => Result.ok(generateBriefReport(app)),
344
- detail: (app, input) => buildSurveyDetail(app, input.trailId ?? ''),
138
+ brief: (app, _input, rootDir) =>
139
+ Result.ok(buildCurrentTopoBrief(app, { rootDir })),
140
+ detail: (app, input, rootDir) =>
141
+ buildSurveyDetail(app, input.trailId ?? '', rootDir),
345
142
  diff: (app, input) => buildSurveyDiff(app, input.breakingOnly),
346
- generate: (app) => buildSurveyGenerate(app),
347
- list: (app) => Result.ok(generateSurveyList(app)),
143
+ generate: (app, _input, rootDir) => buildSurveyGenerate(app, rootDir),
144
+ list: (app, _input, rootDir) =>
145
+ Result.ok(buildCurrentTopoList(app, { rootDir })),
348
146
  openapi: (app) => Result.ok(generateOpenApiSpec(app)),
349
147
  };
350
148
 
351
149
  /** Dispatch to the appropriate survey sub-command based on input flags. */
352
150
  const dispatchSurvey = (
353
151
  app: Topo,
354
- input: SurveyInput
152
+ input: SurveyInput,
153
+ rootDir: string
355
154
  ): Result<object, Error> | Promise<Result<object, Error>> => {
356
155
  const mode = resolveSurveyMode(input);
357
156
  const handler = surveyHandlers[mode];
358
- return handler(app, input);
157
+ return handler(app, input, rootDir);
359
158
  };
360
159
 
361
160
  // ---------------------------------------------------------------------------
@@ -364,8 +163,9 @@ const dispatchSurvey = (
364
163
 
365
164
  export const surveyTrail = trail('survey', {
366
165
  blaze: async (input, ctx) => {
367
- const app = await loadApp(input.module, ctx.cwd ?? '.');
368
- return dispatchSurvey(app, input);
166
+ const rootDir = ctx.cwd ?? '.';
167
+ const app = await loadApp(input.module, rootDir);
168
+ return dispatchSurvey(app, input, rootDir);
369
169
  },
370
170
  description: 'Full topo introspection',
371
171
  examples: [
@@ -0,0 +1,2 @@
1
+ export const REPORT_CONTRACT_VERSION = '2026-03';
2
+ export const REPORT_VERSION = '0.1.0';
@@ -0,0 +1,39 @@
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
+ DEFAULT_APP_MODULE,
8
+ isolatedExampleInput,
9
+ topoSaveOutput,
10
+ } from './topo-support.js';
11
+
12
+ export const topoExportTrail = trail('topo.export', {
13
+ blaze: async (input, ctx) => {
14
+ const rootDir = input.rootDir ?? ctx.cwd ?? process.cwd();
15
+ const app = await loadApp(input.module, rootDir);
16
+ return exportCurrentTopo(app, { rootDir });
17
+ },
18
+ description: 'Export the current topo to .trails artifacts',
19
+ examples: [
20
+ {
21
+ input: isolatedExampleInput('topo-export'),
22
+ name: 'Write the current topo export',
23
+ },
24
+ ],
25
+ input: z.object({
26
+ module: z
27
+ .string()
28
+ .default(DEFAULT_APP_MODULE)
29
+ .describe('Path to the app module'),
30
+ rootDir: z.string().optional().describe('Workspace root directory'),
31
+ }),
32
+ intent: 'write',
33
+ output: z.object({
34
+ hash: z.string(),
35
+ lockPath: z.string(),
36
+ mapPath: z.string(),
37
+ save: topoSaveOutput,
38
+ }),
39
+ });
@@ -0,0 +1,40 @@
1
+ import { Result, trail } from '@ontrails/core';
2
+ import { z } from 'zod';
3
+
4
+ import {
5
+ DEFAULT_TOPO_HISTORY_LIMIT,
6
+ isolatedExampleInput,
7
+ listTopoHistory,
8
+ topoPinOutput,
9
+ topoSaveOutput,
10
+ } from './topo-support.js';
11
+
12
+ export const topoHistoryTrail = trail('topo.history', {
13
+ blaze: (input, ctx) => {
14
+ const rootDir = input.rootDir ?? ctx.cwd ?? process.cwd();
15
+ return Result.ok(listTopoHistory({ limit: input.limit, rootDir }));
16
+ },
17
+ description: 'List saved topo metadata, including pins and recent autosaves',
18
+ examples: [
19
+ {
20
+ input: isolatedExampleInput('topo-history'),
21
+ name: 'Show topo history',
22
+ },
23
+ ],
24
+ input: z.object({
25
+ limit: z
26
+ .number()
27
+ .default(DEFAULT_TOPO_HISTORY_LIMIT)
28
+ .describe('Maximum number of autosaves to return'),
29
+ rootDir: z.string().optional().describe('Workspace root directory'),
30
+ }),
31
+ intent: 'read',
32
+ output: z.object({
33
+ dbPath: z.string(),
34
+ limit: z.number(),
35
+ pinCount: z.number(),
36
+ pins: z.array(topoPinOutput),
37
+ saveCount: z.number(),
38
+ saves: z.array(topoSaveOutput),
39
+ }),
40
+ });