@ontrails/trails 1.0.0-beta.12 → 1.0.0-beta.13
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.
- package/CHANGELOG.md +24 -12
- package/package.json +1 -1
- package/src/__tests__/create.test.ts +35 -33
- package/src/__tests__/guide.test.ts +4 -4
- package/src/__tests__/survey.test.ts +55 -55
- package/src/__tests__/warden.test.ts +2 -2
- package/src/app.ts +2 -2
- package/src/clack.ts +1 -1
- package/src/cli.ts +2 -2
- package/src/trails/add-trail.ts +13 -13
- package/src/trails/{add-surface.ts → add-trailhead.ts} +39 -37
- package/src/trails/add-verify.ts +10 -10
- package/src/trails/create-scaffold.ts +28 -28
- package/src/trails/create.ts +42 -42
- package/src/trails/guide.ts +14 -14
- package/src/trails/survey.ts +83 -82
- package/src/trails/warden.ts +32 -32
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# trails
|
|
2
2
|
|
|
3
|
+
## 1.0.0-beta.13
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Updated dependencies [6944147]
|
|
8
|
+
- Updated dependencies
|
|
9
|
+
- @ontrails/core@1.0.0-beta.13
|
|
10
|
+
- @ontrails/cli@1.0.0-beta.13
|
|
11
|
+
- @ontrails/schema@1.0.0-beta.13
|
|
12
|
+
- @ontrails/warden@1.0.0-beta.13
|
|
13
|
+
- @ontrails/logging@1.0.0-beta.13
|
|
14
|
+
|
|
3
15
|
## 1.0.0-beta.12
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
|
@@ -17,17 +29,17 @@
|
|
|
17
29
|
|
|
18
30
|
- Add services as a first-class primitive.
|
|
19
31
|
|
|
20
|
-
Services make infrastructure dependencies declarative, injectable, and governable. Define a service with `
|
|
32
|
+
Services make infrastructure dependencies declarative, injectable, and governable. Define a service with `provision()`, declare it on a trail with `provisions: [db]`, and access it with `db.from(ctx)` or `ctx.provision()`.
|
|
21
33
|
|
|
22
|
-
**Core:** `
|
|
34
|
+
**Core:** `provision()` factory, `ServiceSpec<T>`, `ServiceContext`, singleton resolution in `executeTrail`, in-flight creation dedup, `isService` guard, `findDuplicateServiceId`, topo service discovery and validation, `services` field on trail specs.
|
|
23
35
|
|
|
24
|
-
**Testing:** Auto-resolution of `mock` factories in `testAll`, `testExamples`, `testContracts`, and `
|
|
36
|
+
**Testing:** Auto-resolution of `mock` factories in `testAll`, `testExamples`, `testContracts`, and `testCrosses`. Explicit `services` overrides with correct precedence (`explicit > ctx.extensions > auto-mock`). Service mock propagation through crossing graphs.
|
|
25
37
|
|
|
26
|
-
**Warden:** `service-declarations` rule validates `db.from(ctx)` and `ctx.
|
|
38
|
+
**Warden:** `service-declarations` rule validates `db.from(ctx)` and `ctx.provision()` usage matches declared `provisions: [...]`. `service-exists` rule validates declared service IDs resolve in project context. Scope-aware AST walking skips nested function boundaries.
|
|
27
39
|
|
|
28
|
-
**
|
|
40
|
+
**Trailheads:** Service overrides thread through `run` and `trailhead` on CLI, MCP, and HTTP.
|
|
29
41
|
|
|
30
|
-
**Introspection:** Survey and
|
|
42
|
+
**Introspection:** Survey and trailhead map outputs include service graph. Topo exposes `.services`, `.getService()`, `.hasService()`, `.listServices()`, `.serviceIds()`, `.serviceCount`.
|
|
31
43
|
|
|
32
44
|
**Docs:** ADR-009 accepted. Unified services guide, updated vocabulary, getting-started, architecture, and package READMEs.
|
|
33
45
|
|
|
@@ -75,9 +87,9 @@
|
|
|
75
87
|
|
|
76
88
|
### Minor Changes
|
|
77
89
|
|
|
78
|
-
- HTTP
|
|
90
|
+
- HTTP trailhead and OpenAPI generation.
|
|
79
91
|
|
|
80
|
-
**http**: New `@ontrails/http` package — Hono-based HTTP
|
|
92
|
+
**http**: New `@ontrails/http` package — Hono-based HTTP connector. `trailhead()` derives routes from trail IDs, maps intent to HTTP verbs (read→GET, write→POST, destroy→DELETE), and maps error taxonomy to status codes. Returns the Hono instance.
|
|
81
93
|
|
|
82
94
|
**schema**: Add `generateOpenApiSpec(topo)` — generates a complete OpenAPI 3.1 spec from the topo. Each trail becomes an operation with path, method, schemas, and error responses derived from the contract.
|
|
83
95
|
|
|
@@ -122,15 +134,15 @@
|
|
|
122
134
|
|
|
123
135
|
**BREAKING CHANGES:**
|
|
124
136
|
|
|
125
|
-
- `hike()` removed — use `trail()` with optional `
|
|
126
|
-
- `follows` renamed to `
|
|
137
|
+
- `hike()` removed — use `trail()` with optional `crosses: [...]` field
|
|
138
|
+
- `follows` renamed to `crosses` (matching `ctx.cross()`)
|
|
127
139
|
- `topo.hikes` removed — single `topo.trails` map
|
|
128
140
|
- `kind: 'hike'` removed — everything is `kind: 'trail'`
|
|
129
141
|
- `readOnly`/`destructive` booleans replaced by `intent: 'read' | 'write' | 'destroy'`
|
|
130
142
|
- `implementation` field renamed to `run`
|
|
131
143
|
- `markers` field renamed to `metadata`
|
|
132
|
-
- `testHike` renamed to `
|
|
133
|
-
- `
|
|
144
|
+
- `testHike` renamed to `testCrosses`, `HikeScenario` to `CrossScenario`
|
|
145
|
+
- `trailhead()` now returns the trailhead handle (`Command` for CLI, `Server` for MCP)
|
|
134
146
|
|
|
135
147
|
### Patch Changes
|
|
136
148
|
|
package/package.json
CHANGED
|
@@ -11,19 +11,19 @@ import { basename, dirname, join } from 'node:path';
|
|
|
11
11
|
|
|
12
12
|
import { Result } from '@ontrails/core';
|
|
13
13
|
|
|
14
|
-
import {
|
|
14
|
+
import { addTrailhead } from '../trails/add-trailhead.js';
|
|
15
15
|
import { addVerify } from '../trails/add-verify.js';
|
|
16
16
|
import { createRoute } from '../trails/create.js';
|
|
17
17
|
import { createScaffold } from '../trails/create-scaffold.js';
|
|
18
18
|
import { isInsideProject } from '../trails/project.js';
|
|
19
19
|
|
|
20
20
|
type Starter = 'empty' | 'entity' | 'hello';
|
|
21
|
-
type
|
|
21
|
+
type Trailhead = 'cli' | 'mcp';
|
|
22
22
|
|
|
23
23
|
const makeTempProject = (): string =>
|
|
24
24
|
join(
|
|
25
25
|
tmpdir(),
|
|
26
|
-
`trails-
|
|
26
|
+
`trails-create-test-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
|
27
27
|
);
|
|
28
28
|
|
|
29
29
|
const readJson = (dir: string, relativePath: string): Record<string, unknown> =>
|
|
@@ -68,43 +68,43 @@ const expectErr = <T>(result: Result<T, Error>): Error => {
|
|
|
68
68
|
return result.error;
|
|
69
69
|
};
|
|
70
70
|
|
|
71
|
-
const
|
|
71
|
+
const runCross = async (
|
|
72
72
|
id: string,
|
|
73
73
|
input: unknown
|
|
74
74
|
): Promise<Result<unknown, Error>> => {
|
|
75
75
|
switch (id) {
|
|
76
76
|
case 'create.scaffold': {
|
|
77
|
-
return await createScaffold.
|
|
77
|
+
return await createScaffold.blaze(input as never, {} as never);
|
|
78
78
|
}
|
|
79
|
-
case 'add.
|
|
80
|
-
return await
|
|
79
|
+
case 'add.trailhead': {
|
|
80
|
+
return await addTrailhead.blaze(input as never, {} as never);
|
|
81
81
|
}
|
|
82
82
|
case 'add.verify': {
|
|
83
|
-
return await addVerify.
|
|
83
|
+
return await addVerify.blaze(input as never, {} as never);
|
|
84
84
|
}
|
|
85
85
|
default: {
|
|
86
|
-
return Result.err(new Error(`Unknown
|
|
86
|
+
return Result.err(new Error(`Unknown cross target: ${id}`));
|
|
87
87
|
}
|
|
88
88
|
}
|
|
89
89
|
};
|
|
90
90
|
|
|
91
|
-
const
|
|
91
|
+
const runCreate = (
|
|
92
92
|
projectDir: string,
|
|
93
93
|
overrides?: Partial<{
|
|
94
94
|
starter: Starter;
|
|
95
|
-
|
|
95
|
+
trailheads: readonly Trailhead[];
|
|
96
96
|
verify: boolean;
|
|
97
97
|
}>
|
|
98
98
|
) =>
|
|
99
|
-
createRoute.
|
|
99
|
+
createRoute.blaze(
|
|
100
100
|
{
|
|
101
101
|
dir: dirname(projectDir),
|
|
102
102
|
name: basename(projectDir),
|
|
103
103
|
starter: overrides?.starter ?? 'hello',
|
|
104
|
-
|
|
104
|
+
trailheads: [...(overrides?.trailheads ?? ['cli'])],
|
|
105
105
|
verify: overrides?.verify ?? true,
|
|
106
106
|
},
|
|
107
|
-
{
|
|
107
|
+
{ cross: runCross } as never
|
|
108
108
|
);
|
|
109
109
|
|
|
110
110
|
const setupMinimalProject = (dir: string): void => {
|
|
@@ -177,7 +177,7 @@ const assertEntityStarter = (dir: string): void => {
|
|
|
177
177
|
'src/trails/entity.ts',
|
|
178
178
|
'src/trails/search.ts',
|
|
179
179
|
'src/trails/onboard.ts',
|
|
180
|
-
'src/
|
|
180
|
+
'src/signals/entity-signals.ts',
|
|
181
181
|
'src/store.ts',
|
|
182
182
|
],
|
|
183
183
|
true
|
|
@@ -187,7 +187,7 @@ const assertEntityStarter = (dir: string): void => {
|
|
|
187
187
|
"import * as entity from './trails/entity.js'",
|
|
188
188
|
"import * as search from './trails/search.js'",
|
|
189
189
|
"import * as onboard from './trails/onboard.js'",
|
|
190
|
-
"import * as
|
|
190
|
+
"import * as entitySignals from './signals/entity-signals.js'",
|
|
191
191
|
]);
|
|
192
192
|
expectContainsAll(readText(dir, 'src/trails/entity.ts'), [
|
|
193
193
|
"import { Result, trail } from '@ontrails/core'",
|
|
@@ -204,12 +204,12 @@ const assertEntityStarter = (dir: string): void => {
|
|
|
204
204
|
]);
|
|
205
205
|
};
|
|
206
206
|
|
|
207
|
-
const
|
|
207
|
+
const assertMcpTrailhead = (dir: string): void => {
|
|
208
208
|
expectPaths(dir, ['src/mcp.ts'], true);
|
|
209
209
|
expectPaths(dir, ['src/cli.ts'], false);
|
|
210
210
|
expectContainsAll(readText(dir, 'src/mcp.ts'), [
|
|
211
|
-
"import {
|
|
212
|
-
'await
|
|
211
|
+
"import { trailhead } from '@ontrails/mcp'",
|
|
212
|
+
'await trailhead(app)',
|
|
213
213
|
]);
|
|
214
214
|
|
|
215
215
|
const deps = readJson(dir, 'package.json')['dependencies'] as Record<
|
|
@@ -244,11 +244,11 @@ const withTempProject = async (
|
|
|
244
244
|
}
|
|
245
245
|
};
|
|
246
246
|
|
|
247
|
-
describe('trails
|
|
247
|
+
describe('trails create', () => {
|
|
248
248
|
describe('create mode', () => {
|
|
249
249
|
test('generates project structure with defaults', async () => {
|
|
250
250
|
await withTempProject(async (dir) => {
|
|
251
|
-
expectOk(await
|
|
251
|
+
expectOk(await runCreate(dir));
|
|
252
252
|
assertDefaultProjectFiles(dir);
|
|
253
253
|
assertCliPackage(dir);
|
|
254
254
|
assertHelloApp(dir);
|
|
@@ -257,46 +257,46 @@ describe('trails blaze', () => {
|
|
|
257
257
|
|
|
258
258
|
test('generates with entity starter', async () => {
|
|
259
259
|
await withTempProject(async (dir) => {
|
|
260
|
-
expectOk(await
|
|
260
|
+
expectOk(await runCreate(dir, { starter: 'entity' }));
|
|
261
261
|
assertEntityStarter(dir);
|
|
262
262
|
});
|
|
263
263
|
});
|
|
264
264
|
|
|
265
|
-
test('generates with MCP
|
|
265
|
+
test('generates with MCP trailhead', async () => {
|
|
266
266
|
await withTempProject(async (dir) => {
|
|
267
|
-
expectOk(await
|
|
268
|
-
|
|
267
|
+
expectOk(await runCreate(dir, { trailheads: ['mcp'] }));
|
|
268
|
+
assertMcpTrailhead(dir);
|
|
269
269
|
});
|
|
270
270
|
});
|
|
271
271
|
|
|
272
272
|
test('skips verification when verify is false', async () => {
|
|
273
273
|
await withTempProject(async (dir) => {
|
|
274
|
-
expectOk(await
|
|
274
|
+
expectOk(await runCreate(dir, { verify: false }));
|
|
275
275
|
assertVerifySkipped(dir);
|
|
276
276
|
});
|
|
277
277
|
});
|
|
278
278
|
|
|
279
279
|
test('generates with empty starter', async () => {
|
|
280
280
|
await withTempProject(async (dir) => {
|
|
281
|
-
expectOk(await
|
|
281
|
+
expectOk(await runCreate(dir, { starter: 'empty' }));
|
|
282
282
|
assertEmptyStarter(dir);
|
|
283
283
|
});
|
|
284
284
|
});
|
|
285
285
|
});
|
|
286
286
|
|
|
287
|
-
describe('add-
|
|
287
|
+
describe('add-trailhead mode', () => {
|
|
288
288
|
test('adds MCP to existing project', async () => {
|
|
289
289
|
await withTempProject(async (dir) => {
|
|
290
290
|
setupMinimalProject(dir);
|
|
291
291
|
const result = expectOk(
|
|
292
|
-
await
|
|
292
|
+
await addTrailhead.blaze({ dir, trailhead: 'mcp' }, {} as never)
|
|
293
293
|
);
|
|
294
294
|
|
|
295
295
|
expect(result.created).toBe('src/mcp.ts');
|
|
296
296
|
expect(result.dependency).toBe('@ontrails/mcp');
|
|
297
297
|
expectPaths(dir, ['src/mcp.ts'], true);
|
|
298
298
|
expectContainsAll(readText(dir, 'src/mcp.ts'), [
|
|
299
|
-
"import {
|
|
299
|
+
"import { trailhead } from '@ontrails/mcp'",
|
|
300
300
|
]);
|
|
301
301
|
const deps = readJson(dir, 'package.json')['dependencies'] as Record<
|
|
302
302
|
string,
|
|
@@ -306,16 +306,18 @@ describe('trails blaze', () => {
|
|
|
306
306
|
});
|
|
307
307
|
});
|
|
308
308
|
|
|
309
|
-
test('detects
|
|
309
|
+
test('detects existing trailhead entrypoint', async () => {
|
|
310
310
|
await withTempProject(async (dir) => {
|
|
311
311
|
mkdirSync(join(dir, 'src'), { recursive: true });
|
|
312
312
|
mkdirSync(join(dir, '.trails'), { recursive: true });
|
|
313
313
|
writeFileSync(join(dir, 'src', 'mcp.ts'), 'existing content');
|
|
314
314
|
|
|
315
315
|
const error = expectErr(
|
|
316
|
-
await
|
|
316
|
+
await addTrailhead.blaze({ dir, trailhead: 'mcp' }, {} as never)
|
|
317
|
+
);
|
|
318
|
+
expect(error.message).toBe(
|
|
319
|
+
'MCP trailhead already exists. Nothing to do.'
|
|
317
320
|
);
|
|
318
|
-
expect(error.message).toBe('MCP is already blazed. Nothing to do.');
|
|
319
321
|
});
|
|
320
322
|
});
|
|
321
323
|
});
|
|
@@ -9,6 +9,10 @@ import { z } from 'zod';
|
|
|
9
9
|
// ---------------------------------------------------------------------------
|
|
10
10
|
|
|
11
11
|
const helloTrail = trail('hello', {
|
|
12
|
+
blaze: (input) => {
|
|
13
|
+
const name = input.name ?? 'world';
|
|
14
|
+
return Result.ok({ message: `Hello, ${name}!` });
|
|
15
|
+
},
|
|
12
16
|
description: 'Say hello',
|
|
13
17
|
detours: {
|
|
14
18
|
NotFoundError: ['search'],
|
|
@@ -28,10 +32,6 @@ const helloTrail = trail('hello', {
|
|
|
28
32
|
input: z.object({ name: z.string().optional() }),
|
|
29
33
|
intent: 'read',
|
|
30
34
|
output: z.object({ message: z.string() }),
|
|
31
|
-
run: (input) => {
|
|
32
|
-
const name = input.name ?? 'world';
|
|
33
|
-
return Result.ok({ message: `Hello, ${name}!` });
|
|
34
|
-
},
|
|
35
35
|
});
|
|
36
36
|
|
|
37
37
|
const app = topo('test-app', { hello: helloTrail });
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test';
|
|
2
2
|
|
|
3
|
-
import { Result,
|
|
3
|
+
import { Result, provision, topo, trail } from '@ontrails/core';
|
|
4
4
|
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
generateTrailheadMap,
|
|
6
|
+
hashTrailheadMap,
|
|
7
|
+
diffTrailheadMaps,
|
|
8
8
|
} from '@ontrails/schema';
|
|
9
|
-
import type {
|
|
9
|
+
import type { TrailheadMap } from '@ontrails/schema';
|
|
10
10
|
import { z } from 'zod';
|
|
11
11
|
|
|
12
12
|
import {
|
|
@@ -25,6 +25,10 @@ import type {
|
|
|
25
25
|
// ---------------------------------------------------------------------------
|
|
26
26
|
|
|
27
27
|
const helloTrail = trail('hello', {
|
|
28
|
+
blaze: (input) => {
|
|
29
|
+
const name = input.name ?? 'world';
|
|
30
|
+
return Result.ok({ message: `Hello, ${name}!` });
|
|
31
|
+
},
|
|
28
32
|
description: 'Say hello',
|
|
29
33
|
detours: {
|
|
30
34
|
NotFoundError: ['search'],
|
|
@@ -39,32 +43,28 @@ const helloTrail = trail('hello', {
|
|
|
39
43
|
input: z.object({ name: z.string().optional() }),
|
|
40
44
|
intent: 'read',
|
|
41
45
|
output: z.object({ message: z.string() }),
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
return Result.ok({ message: `Hello, ${name}!` });
|
|
45
|
-
},
|
|
46
|
-
services: [
|
|
47
|
-
service('db.main', {
|
|
46
|
+
provisions: [
|
|
47
|
+
provision('db.main', {
|
|
48
48
|
create: () => Result.ok({ source: 'factory' }),
|
|
49
49
|
}),
|
|
50
50
|
],
|
|
51
51
|
});
|
|
52
52
|
|
|
53
53
|
const byeTrail = trail('bye', {
|
|
54
|
+
blaze: (input) => Result.ok({ message: `Goodbye, ${input.name}!` }),
|
|
54
55
|
description: 'Say goodbye',
|
|
55
56
|
input: z.object({ name: z.string() }),
|
|
56
57
|
output: z.object({ message: z.string() }),
|
|
57
|
-
run: (input) => Result.ok({ message: `Goodbye, ${input.name}!` }),
|
|
58
58
|
});
|
|
59
59
|
|
|
60
|
-
const [
|
|
61
|
-
if (!
|
|
60
|
+
const [dbProvision] = helloTrail.provisions;
|
|
61
|
+
if (!dbProvision) {
|
|
62
62
|
throw new Error('Expected helloTrail to declare db.main');
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
const app = topo('test-app', {
|
|
66
66
|
bye: byeTrail,
|
|
67
|
-
|
|
67
|
+
dbProvision,
|
|
68
68
|
hello: helloTrail,
|
|
69
69
|
});
|
|
70
70
|
|
|
@@ -73,46 +73,46 @@ const app = topo('test-app', {
|
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
74
|
|
|
75
75
|
describe('trails survey', () => {
|
|
76
|
-
test('
|
|
77
|
-
const
|
|
78
|
-
expect(
|
|
79
|
-
const ids =
|
|
76
|
+
test('generateTrailheadMap includes all trails', () => {
|
|
77
|
+
const trailheadMap = generateTrailheadMap(app);
|
|
78
|
+
expect(trailheadMap.entries.length).toBe(3);
|
|
79
|
+
const ids = trailheadMap.entries.map((e) => e.id);
|
|
80
80
|
expect(ids).toContain('hello');
|
|
81
81
|
expect(ids).toContain('bye');
|
|
82
82
|
expect(ids).toContain('db.main');
|
|
83
83
|
});
|
|
84
84
|
|
|
85
|
-
test('
|
|
86
|
-
const
|
|
87
|
-
const hello =
|
|
85
|
+
test('trailhead map entries have expected fields', () => {
|
|
86
|
+
const trailheadMap = generateTrailheadMap(app);
|
|
87
|
+
const hello = trailheadMap.entries.find((e) => e.id === 'hello');
|
|
88
88
|
expect(hello).toBeDefined();
|
|
89
89
|
expect(hello?.kind).toBe('trail');
|
|
90
90
|
expect(hello?.intent).toBe('read');
|
|
91
91
|
expect(hello?.exampleCount).toBe(1);
|
|
92
|
-
expect(hello?.
|
|
92
|
+
expect(hello?.provisions).toEqual(['db.main']);
|
|
93
93
|
});
|
|
94
94
|
|
|
95
95
|
test('JSON output is valid JSON', () => {
|
|
96
|
-
const
|
|
97
|
-
const json = JSON.stringify(
|
|
98
|
-
const parsed = JSON.parse(json) as
|
|
96
|
+
const trailheadMap = generateTrailheadMap(app);
|
|
97
|
+
const json = JSON.stringify(trailheadMap, null, 2);
|
|
98
|
+
const parsed = JSON.parse(json) as TrailheadMap;
|
|
99
99
|
expect(parsed.version).toBe('1.0');
|
|
100
100
|
expect(parsed.entries.length).toBe(3);
|
|
101
101
|
});
|
|
102
102
|
|
|
103
|
-
test('
|
|
104
|
-
const
|
|
105
|
-
const hash1 =
|
|
106
|
-
const hash2 =
|
|
103
|
+
test('hashTrailheadMap produces stable hash', () => {
|
|
104
|
+
const trailheadMap = generateTrailheadMap(app);
|
|
105
|
+
const hash1 = hashTrailheadMap(trailheadMap);
|
|
106
|
+
const hash2 = hashTrailheadMap(trailheadMap);
|
|
107
107
|
expect(hash1).toBe(hash2);
|
|
108
108
|
// SHA-256 hex
|
|
109
109
|
expect(hash1.length).toBe(64);
|
|
110
110
|
});
|
|
111
111
|
|
|
112
|
-
test('
|
|
113
|
-
const prev =
|
|
114
|
-
const curr =
|
|
115
|
-
const diff =
|
|
112
|
+
test('diffTrailheadMaps detects added trails', () => {
|
|
113
|
+
const prev = generateTrailheadMap(topo('test', { hello: helloTrail }));
|
|
114
|
+
const curr = generateTrailheadMap(app);
|
|
115
|
+
const diff = diffTrailheadMaps(prev, curr);
|
|
116
116
|
|
|
117
117
|
expect(diff.info.length).toBeGreaterThan(0);
|
|
118
118
|
const addedBye = diff.info.find((e) => e.id === 'bye');
|
|
@@ -120,10 +120,10 @@ describe('trails survey', () => {
|
|
|
120
120
|
expect(addedBye?.change).toBe('added');
|
|
121
121
|
});
|
|
122
122
|
|
|
123
|
-
test('
|
|
124
|
-
const prev =
|
|
125
|
-
const curr =
|
|
126
|
-
const diff =
|
|
123
|
+
test('diffTrailheadMaps detects removed trails', () => {
|
|
124
|
+
const prev = generateTrailheadMap(app);
|
|
125
|
+
const curr = generateTrailheadMap(topo('test', { hello: helloTrail }));
|
|
126
|
+
const diff = diffTrailheadMaps(prev, curr);
|
|
127
127
|
|
|
128
128
|
expect(diff.hasBreaking).toBe(true);
|
|
129
129
|
const removedBye = diff.breaking.find((e) => e.id === 'bye');
|
|
@@ -131,9 +131,9 @@ describe('trails survey', () => {
|
|
|
131
131
|
expect(removedBye?.change).toBe('removed');
|
|
132
132
|
});
|
|
133
133
|
|
|
134
|
-
test('
|
|
135
|
-
const
|
|
136
|
-
const diff =
|
|
134
|
+
test('diffTrailheadMaps returns empty for identical maps', () => {
|
|
135
|
+
const trailheadMap = generateTrailheadMap(app);
|
|
136
|
+
const diff = diffTrailheadMaps(trailheadMap, trailheadMap);
|
|
137
137
|
expect(diff.entries.length).toBe(0);
|
|
138
138
|
expect(diff.hasBreaking).toBe(false);
|
|
139
139
|
});
|
|
@@ -153,8 +153,8 @@ describe('trails survey --brief', () => {
|
|
|
153
153
|
test('report includes correct trail count', () => {
|
|
154
154
|
const report = generateBriefReport(app);
|
|
155
155
|
expect(report.trails).toBe(2);
|
|
156
|
-
expect(report.
|
|
157
|
-
expect(report.
|
|
156
|
+
expect(report.signals).toBe(0);
|
|
157
|
+
expect(report.provisions).toBe(1);
|
|
158
158
|
});
|
|
159
159
|
|
|
160
160
|
test('detects features in use', () => {
|
|
@@ -162,8 +162,8 @@ describe('trails survey --brief', () => {
|
|
|
162
162
|
expect(report.features.outputSchemas).toBe(true);
|
|
163
163
|
expect(report.features.examples).toBe(true);
|
|
164
164
|
expect(report.features.detours).toBe(true);
|
|
165
|
-
expect(report.features.
|
|
166
|
-
expect(report.features.
|
|
165
|
+
expect(report.features.signals).toBe(false);
|
|
166
|
+
expect(report.features.provisions).toBe(true);
|
|
167
167
|
});
|
|
168
168
|
|
|
169
169
|
test('JSON output is valid', () => {
|
|
@@ -172,7 +172,7 @@ describe('trails survey --brief', () => {
|
|
|
172
172
|
const parsed = JSON.parse(json) as BriefReport;
|
|
173
173
|
expect(parsed.name).toBe('test-app');
|
|
174
174
|
expect(parsed.trails).toBe(2);
|
|
175
|
-
expect(parsed.
|
|
175
|
+
expect(parsed.provisions).toBe(1);
|
|
176
176
|
});
|
|
177
177
|
|
|
178
178
|
test('empty app reports zero features', () => {
|
|
@@ -182,33 +182,33 @@ describe('trails survey --brief', () => {
|
|
|
182
182
|
expect(report.features.outputSchemas).toBe(false);
|
|
183
183
|
expect(report.features.examples).toBe(false);
|
|
184
184
|
expect(report.features.detours).toBe(false);
|
|
185
|
-
expect(report.features.
|
|
185
|
+
expect(report.features.provisions).toBe(false);
|
|
186
186
|
});
|
|
187
187
|
});
|
|
188
188
|
|
|
189
189
|
describe('trails survey detail', () => {
|
|
190
|
-
test('trail detail includes declared
|
|
190
|
+
test('trail detail includes declared provisions, crossings, and intent', () => {
|
|
191
191
|
const detail = generateTrailDetail(helloTrail);
|
|
192
192
|
const parsed = structuredClone(detail) as TrailDetailReport;
|
|
193
193
|
|
|
194
|
-
expect(parsed.
|
|
194
|
+
expect(parsed.crosses).toEqual([]);
|
|
195
195
|
expect(parsed.intent).toBe('read');
|
|
196
|
-
expect(parsed.
|
|
196
|
+
expect(parsed.provisions).toEqual(['db.main']);
|
|
197
197
|
});
|
|
198
198
|
});
|
|
199
199
|
|
|
200
|
-
describe('trails survey
|
|
201
|
-
test('list output includes
|
|
200
|
+
describe('trails survey provisions section', () => {
|
|
201
|
+
test('list output includes provision lifetime and health status', () => {
|
|
202
202
|
const report = generateSurveyList(app);
|
|
203
203
|
const parsed = structuredClone(report) as SurveyListReport;
|
|
204
|
-
const db = parsed.
|
|
204
|
+
const db = parsed.provisions.find((entry) => entry.id === 'db.main');
|
|
205
205
|
|
|
206
|
-
expect(parsed.
|
|
206
|
+
expect(parsed.provisionCount).toBe(1);
|
|
207
207
|
expect(db).toEqual({
|
|
208
208
|
description: null,
|
|
209
209
|
health: 'none',
|
|
210
210
|
id: 'db.main',
|
|
211
|
-
kind: '
|
|
211
|
+
kind: 'provision',
|
|
212
212
|
lifetime: 'singleton',
|
|
213
213
|
usedBy: ['hello'],
|
|
214
214
|
});
|
|
@@ -21,7 +21,7 @@ describe('trails warden', () => {
|
|
|
21
21
|
writeFileSync(
|
|
22
22
|
join(dir, 'good.ts'),
|
|
23
23
|
`trail("hello", {
|
|
24
|
-
|
|
24
|
+
blaze: async (input, ctx) => {
|
|
25
25
|
return Result.ok({ message: "hi" });
|
|
26
26
|
}
|
|
27
27
|
})`
|
|
@@ -54,7 +54,7 @@ describe('trails warden', () => {
|
|
|
54
54
|
writeFileSync(
|
|
55
55
|
join(dir, 'bad.ts'),
|
|
56
56
|
`trail("x", {
|
|
57
|
-
|
|
57
|
+
blaze: async () => { throw new Error("boom"); }
|
|
58
58
|
})`
|
|
59
59
|
);
|
|
60
60
|
const report = await runWarden({ driftOnly: true, rootDir: dir });
|
package/src/app.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { topo } from '@ontrails/core';
|
|
2
2
|
|
|
3
|
-
import * as
|
|
3
|
+
import * as addTrailhead from './trails/add-trailhead.js';
|
|
4
4
|
import * as addTrail from './trails/add-trail.js';
|
|
5
5
|
import * as addVerify from './trails/add-verify.js';
|
|
6
6
|
import * as create from './trails/create.js';
|
|
@@ -16,7 +16,7 @@ export const app = topo(
|
|
|
16
16
|
warden,
|
|
17
17
|
create,
|
|
18
18
|
createScaffold,
|
|
19
|
-
|
|
19
|
+
addTrailhead,
|
|
20
20
|
addVerify,
|
|
21
21
|
addTrail
|
|
22
22
|
);
|
package/src/clack.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Clack-backed input resolver for the Trails CLI.
|
|
3
3
|
*
|
|
4
|
-
* This stays at the app
|
|
4
|
+
* This stays at the app gate so @ontrails/cli remains prompt-library agnostic.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { Field, InputResolver, ResolveInputOptions } from '@ontrails/cli';
|
package/src/cli.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { outputModePreset } from '@ontrails/cli';
|
|
2
|
-
import {
|
|
2
|
+
import { trailhead } from '@ontrails/cli/commander';
|
|
3
3
|
|
|
4
4
|
import { app } from './app.js';
|
|
5
5
|
import { resolveInputWithClack } from './clack.js';
|
|
6
6
|
|
|
7
7
|
// oxlint-disable-next-line require-hook -- CLI entry point
|
|
8
|
-
|
|
8
|
+
trailhead(app, {
|
|
9
9
|
description: 'Agent-native, contract-first TypeScript framework',
|
|
10
10
|
name: 'trails',
|
|
11
11
|
presets: [outputModePreset()],
|
package/src/trails/add-trail.ts
CHANGED
|
@@ -29,7 +29,7 @@ export const ${id.replaceAll('.', '_')} = trail('${id}', {
|
|
|
29
29
|
name: 'TODO: add example',
|
|
30
30
|
},
|
|
31
31
|
],
|
|
32
|
-
|
|
32
|
+
blaze: async (input) => {
|
|
33
33
|
return Result.ok({ message: 'TODO' });
|
|
34
34
|
},
|
|
35
35
|
input: z.object({}),${intentLine}
|
|
@@ -64,18 +64,7 @@ const writeWithDirs = async (
|
|
|
64
64
|
};
|
|
65
65
|
|
|
66
66
|
export const addTrail = trail('add.trail', {
|
|
67
|
-
|
|
68
|
-
input: z.object({
|
|
69
|
-
id: z.string().describe('Trail ID (e.g., entity.update)'),
|
|
70
|
-
intent: z
|
|
71
|
-
.enum(['read', 'write', 'destroy'])
|
|
72
|
-
.default('write')
|
|
73
|
-
.describe('Trail intent'),
|
|
74
|
-
}),
|
|
75
|
-
output: z.object({
|
|
76
|
-
created: z.array(z.string()),
|
|
77
|
-
}),
|
|
78
|
-
run: async (input, ctx) => {
|
|
67
|
+
blaze: async (input, ctx) => {
|
|
79
68
|
const { id } = input;
|
|
80
69
|
const moduleName = id.replaceAll('.', '-');
|
|
81
70
|
const cwd = resolve(ctx.cwd ?? '.');
|
|
@@ -91,4 +80,15 @@ export const addTrail = trail('add.trail', {
|
|
|
91
80
|
|
|
92
81
|
return Result.ok({ created: [...files.keys()] });
|
|
93
82
|
},
|
|
83
|
+
description: 'Scaffold a new trail with tests and examples',
|
|
84
|
+
input: z.object({
|
|
85
|
+
id: z.string().describe('Trail ID (e.g., entity.update)'),
|
|
86
|
+
intent: z
|
|
87
|
+
.enum(['read', 'write', 'destroy'])
|
|
88
|
+
.default('write')
|
|
89
|
+
.describe('Trail intent'),
|
|
90
|
+
}),
|
|
91
|
+
output: z.object({
|
|
92
|
+
created: z.array(z.string()),
|
|
93
|
+
}),
|
|
94
94
|
});
|