@ontrails/testing 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 (83) hide show
  1. package/.turbo/turbo-lint.log +1 -1
  2. package/CHANGELOG.md +28 -0
  3. package/README.md +11 -11
  4. package/dist/all.d.ts +10 -5
  5. package/dist/all.d.ts.map +1 -1
  6. package/dist/all.js +78 -26
  7. package/dist/all.js.map +1 -1
  8. package/dist/assertions.d.ts +23 -0
  9. package/dist/assertions.d.ts.map +1 -1
  10. package/dist/assertions.js +154 -0
  11. package/dist/assertions.js.map +1 -1
  12. package/dist/context.d.ts +17 -16
  13. package/dist/context.d.ts.map +1 -1
  14. package/dist/context.js +31 -20
  15. package/dist/context.js.map +1 -1
  16. package/dist/contracts.d.ts.map +1 -1
  17. package/dist/contracts.js +9 -5
  18. package/dist/contracts.js.map +1 -1
  19. package/dist/crosses.d.ts +4 -4
  20. package/dist/crosses.d.ts.map +1 -1
  21. package/dist/crosses.js +49 -39
  22. package/dist/crosses.js.map +1 -1
  23. package/dist/detours.d.ts +5 -4
  24. package/dist/detours.d.ts.map +1 -1
  25. package/dist/detours.js +93 -14
  26. package/dist/detours.js.map +1 -1
  27. package/dist/effective-examples.d.ts +30 -0
  28. package/dist/effective-examples.d.ts.map +1 -0
  29. package/dist/effective-examples.js +227 -0
  30. package/dist/effective-examples.js.map +1 -0
  31. package/dist/examples.d.ts +1 -1
  32. package/dist/examples.d.ts.map +1 -1
  33. package/dist/examples.js +79 -41
  34. package/dist/examples.js.map +1 -1
  35. package/dist/harness-cli.d.ts +3 -3
  36. package/dist/harness-cli.d.ts.map +1 -1
  37. package/dist/harness-cli.js +25 -33
  38. package/dist/harness-cli.js.map +1 -1
  39. package/dist/harness-mcp.d.ts +3 -3
  40. package/dist/harness-mcp.d.ts.map +1 -1
  41. package/dist/harness-mcp.js +9 -8
  42. package/dist/harness-mcp.js.map +1 -1
  43. package/dist/index.d.ts +6 -4
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +5 -2
  46. package/dist/index.js.map +1 -1
  47. package/dist/scenario.d.ts +37 -0
  48. package/dist/scenario.d.ts.map +1 -0
  49. package/dist/scenario.js +235 -0
  50. package/dist/scenario.js.map +1 -0
  51. package/dist/types.d.ts +38 -5
  52. package/dist/types.d.ts.map +1 -1
  53. package/package.json +9 -5
  54. package/src/__tests__/all.test.ts +217 -29
  55. package/src/__tests__/context.test.ts +32 -12
  56. package/src/__tests__/contracts.test.ts +72 -18
  57. package/src/__tests__/crosses.test.ts +78 -78
  58. package/src/__tests__/detours.test.ts +176 -19
  59. package/src/__tests__/effective-examples.test.ts +203 -0
  60. package/src/__tests__/examples.test.ts +152 -50
  61. package/src/__tests__/harness-cli.test.ts +90 -0
  62. package/src/__tests__/harness-mcp.test.ts +37 -0
  63. package/src/__tests__/partial-match.test.ts +126 -0
  64. package/src/__tests__/scenario.test.ts +381 -0
  65. package/src/all.ts +149 -12
  66. package/src/assertions.ts +253 -0
  67. package/src/context.ts +64 -38
  68. package/src/contracts.ts +14 -8
  69. package/src/crosses.ts +93 -51
  70. package/src/detours.ts +155 -18
  71. package/src/effective-examples.ts +350 -0
  72. package/src/examples.ts +127 -59
  73. package/src/harness-cli.ts +33 -49
  74. package/src/harness-mcp.ts +9 -8
  75. package/src/index.ts +13 -3
  76. package/src/scenario.ts +370 -0
  77. package/src/types.ts +63 -5
  78. package/tsconfig.tests.json +10 -0
  79. package/tsconfig.tsbuildinfo +1 -1
  80. package/dist/follows.d.ts +0 -38
  81. package/dist/follows.d.ts.map +0 -1
  82. package/dist/follows.js +0 -212
  83. package/dist/follows.js.map +0 -1
@@ -0,0 +1,203 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+
3
+ import { contour, Result, trail } from '@ontrails/core';
4
+ import { z } from 'zod';
5
+
6
+ import { deriveTrailExamples } from '../effective-examples.js';
7
+
8
+ const requireContourExample = (
9
+ contourDef: { examples?: readonly Record<string, unknown>[] },
10
+ index: number
11
+ ) => {
12
+ const example = contourDef.examples?.[index];
13
+ expect(example).toBeDefined();
14
+ if (!example) {
15
+ throw new Error(`Expected contour example at index ${index}`);
16
+ }
17
+ return example;
18
+ };
19
+
20
+ const userContour = contour(
21
+ 'user',
22
+ {
23
+ email: z.string().email(),
24
+ id: z.string().uuid(),
25
+ name: z.string(),
26
+ },
27
+ {
28
+ examples: [
29
+ {
30
+ email: 'ada@example.com',
31
+ id: '550e8400-e29b-41d4-a716-446655440000',
32
+ name: 'Ada',
33
+ },
34
+ {
35
+ email: 'grace@example.com',
36
+ id: '0f31f6ba-6ff0-41ce-9f6b-8d132b6c4b81',
37
+ name: 'Grace',
38
+ },
39
+ ],
40
+ identity: 'id',
41
+ }
42
+ );
43
+
44
+ const gistContour = contour(
45
+ 'gist',
46
+ {
47
+ id: z.string().uuid(),
48
+ ownerId: userContour.id(),
49
+ title: z.string(),
50
+ },
51
+ {
52
+ examples: [
53
+ {
54
+ id: '8f7ef40d-8234-4f73-8de8-4bb8366cf5c0',
55
+ ownerId: '550e8400-e29b-41d4-a716-446655440000',
56
+ title: 'Ada Gist',
57
+ },
58
+ {
59
+ id: 'f104f457-b3fd-4643-87b9-d872c54b8a79',
60
+ ownerId: '0f31f6ba-6ff0-41ce-9f6b-8d132b6c4b81',
61
+ title: 'Grace Gist',
62
+ },
63
+ ],
64
+ identity: 'id',
65
+ }
66
+ );
67
+
68
+ describe('deriveTrailExamples', () => {
69
+ test('prefers authored trail examples over contour-derived fixtures', () => {
70
+ const authoredExample = {
71
+ expected: { email: 'manual@example.com', name: 'Manual' },
72
+ input: { email: 'manual@example.com', name: 'Manual' },
73
+ name: 'Manual example',
74
+ } as const;
75
+
76
+ const trailDef = trail('user.manual', {
77
+ blaze: (input: { email: string; name: string }) => Result.ok(input),
78
+ contours: [userContour],
79
+ examples: [authoredExample],
80
+ input: z.object({ email: z.string().email(), name: z.string() }),
81
+ output: z.object({ email: z.string().email(), name: z.string() }),
82
+ });
83
+
84
+ expect(deriveTrailExamples(trailDef)).toEqual([authoredExample]);
85
+ });
86
+
87
+ test('derives single-contour fixtures and preserves full contour output', () => {
88
+ const firstUserExample = requireContourExample(userContour, 0);
89
+
90
+ const trailDef = trail('user.create', {
91
+ blaze: () => Result.ok(firstUserExample),
92
+ contours: [userContour],
93
+ input: userContour.pick({ email: true, name: true }),
94
+ output: userContour,
95
+ });
96
+
97
+ const examples = deriveTrailExamples(trailDef);
98
+ expect(examples).toHaveLength(2);
99
+ const firstRecord = firstUserExample as Record<string, unknown>;
100
+ expect(examples[0]).toEqual(
101
+ expect.objectContaining({
102
+ expected: firstUserExample,
103
+ // Input is projected down to the keys `trail.input` declares, so
104
+ // only `email` and `name` (from `.pick`) survive.
105
+ input: {
106
+ email: firstRecord.email,
107
+ name: firstRecord.name,
108
+ },
109
+ })
110
+ );
111
+ });
112
+
113
+ test('filters contour fixtures that do not satisfy the trail input schema', () => {
114
+ const trailDef = trail('user.slug-only', {
115
+ blaze: () => Result.ok({ slug: 'unused' }),
116
+ contours: [userContour],
117
+ input: z.object({ slug: z.string() }),
118
+ output: z.object({ slug: z.string() }),
119
+ });
120
+
121
+ expect(deriveTrailExamples(trailDef)).toEqual([]);
122
+ });
123
+
124
+ test('matches cross-contour references and exposes contour-prefixed aliases', () => {
125
+ const trailDef = trail('gist.star', {
126
+ blaze: (input: { gistId: string; userId: string }) => Result.ok(input),
127
+ contours: [userContour, gistContour],
128
+ input: z.object({
129
+ gistId: gistContour.id(),
130
+ userId: userContour.id(),
131
+ }),
132
+ output: z.object({
133
+ gistId: gistContour.shape.id,
134
+ userId: userContour.shape.id,
135
+ }),
136
+ });
137
+
138
+ const examples = deriveTrailExamples(trailDef);
139
+ expect(examples).toHaveLength(2);
140
+ // No contour fixture parses as the output schema on its own (the
141
+ // output expects `gistId` and `userId`, but the fixtures expose
142
+ // `id`, `ownerId`, etc.), so derived examples are left without an
143
+ // `expected` and fall back to schema-only validation at runtime.
144
+ expect(examples).toEqual([
145
+ {
146
+ input: {
147
+ gistId: '8f7ef40d-8234-4f73-8de8-4bb8366cf5c0',
148
+ userId: '550e8400-e29b-41d4-a716-446655440000',
149
+ },
150
+ name: expect.stringContaining('Derived fixture 1'),
151
+ },
152
+ {
153
+ input: {
154
+ gistId: 'f104f457-b3fd-4643-87b9-d872c54b8a79',
155
+ userId: '0f31f6ba-6ff0-41ce-9f6b-8d132b6c4b81',
156
+ },
157
+ name: expect.stringContaining('Derived fixture 2'),
158
+ },
159
+ ]);
160
+ });
161
+
162
+ test('derives fixtures for strict input schemas by projecting to known keys', () => {
163
+ const firstUserExample = requireContourExample(userContour, 0);
164
+
165
+ const trailDef = trail('user.strict-create', {
166
+ blaze: (input: { email: string; name: string }) => Result.ok(input),
167
+ contours: [userContour],
168
+ input: z.object({ email: z.string().email(), name: z.string() }).strict(),
169
+ output: z.object({ email: z.string().email(), name: z.string() }),
170
+ });
171
+
172
+ const examples = deriveTrailExamples(trailDef);
173
+ expect(examples).toHaveLength(2);
174
+ expect(examples[0]?.input).toEqual({
175
+ email: (firstUserExample as { email: string }).email,
176
+ name: (firstUserExample as { name: string }).name,
177
+ });
178
+ });
179
+
180
+ test('does not infer expected from merged input when no contour fixture matches the output', () => {
181
+ // The output schema is a subset of the input shape, so the merged
182
+ // derived input would parse as the output if we tried to infer from
183
+ // it — but that inference is semantically wrong because input and
184
+ // output have distinct meanings. Since no contour fixture matches
185
+ // the output schema (userContour fixtures carry id+email+name, and
186
+ // the strict output only accepts email+name), `expected` must be
187
+ // omitted entirely.
188
+ const trailDef = trail('user.create-strict-output', {
189
+ blaze: (input: { email: string; name: string }) => Result.ok(input),
190
+ contours: [userContour],
191
+ input: z.object({ email: z.string().email(), name: z.string() }),
192
+ output: z
193
+ .object({ email: z.string().email(), name: z.string() })
194
+ .strict(),
195
+ });
196
+
197
+ const examples = deriveTrailExamples(trailDef);
198
+ expect(examples.length).toBeGreaterThan(0);
199
+ for (const example of examples) {
200
+ expect(example.expected).toBeUndefined();
201
+ }
202
+ });
203
+ });
@@ -1,6 +1,16 @@
1
1
  import { describe, test } from 'bun:test';
2
2
 
3
- import { NotFoundError, Result, provision, trail, topo } from '@ontrails/core';
3
+ import {
4
+ ConflictError,
5
+ contour,
6
+ DerivationError,
7
+ NotFoundError,
8
+ Result,
9
+ RetryExhaustedError,
10
+ resource,
11
+ trail,
12
+ topo,
13
+ } from '@ontrails/core';
4
14
  import { z } from 'zod';
5
15
 
6
16
  import { testExamples } from '../examples.js';
@@ -67,41 +77,68 @@ const noExamplesTrail = trail('noexamples', {
67
77
  input: z.object({ x: z.number() }),
68
78
  });
69
79
 
70
- const mockDbProvision = provision('db.mock.examples', {
80
+ const taxonomyErrorTrail = trail('taxonomy.errors', {
81
+ blaze: (input: { type: 'derivation' | 'retry' }) => {
82
+ if (input.type === 'derivation') {
83
+ return Result.err(new DerivationError('could not derive projection'));
84
+ }
85
+ return Result.err(
86
+ new RetryExhaustedError(new ConflictError('version mismatch'), {
87
+ attempts: 3,
88
+ detour: 'ConflictError',
89
+ })
90
+ );
91
+ },
92
+ examples: [
93
+ {
94
+ error: 'DerivationError',
95
+ input: { type: 'derivation' },
96
+ name: 'Derivation failure returns DerivationError',
97
+ },
98
+ {
99
+ error: 'RetryExhaustedError',
100
+ input: { type: 'retry' },
101
+ name: 'Exhausted detour returns RetryExhaustedError',
102
+ },
103
+ ],
104
+ input: z.object({ type: z.enum(['derivation', 'retry']) }),
105
+ });
106
+
107
+ const mockDbResource = resource('db.mock.examples', {
71
108
  create: () => Result.ok({ source: 'factory' }),
72
109
  mock: () => ({ source: 'mock' }),
73
110
  });
74
111
 
75
- const mockProvisionTrail = trail('provision.mocked', {
112
+ const mockResourceTrail = trail('resource.mocked', {
76
113
  blaze: (_input, ctx) =>
77
- Result.ok({ source: mockDbProvision.from(ctx).source }),
78
- description: 'Trail that reads from a mocked provision',
114
+ Result.ok({ source: mockDbResource.from(ctx).source }),
115
+ description: 'Trail that reads from a mocked resource',
79
116
  examples: [
80
117
  {
81
118
  expected: { source: 'mock' },
82
119
  input: {},
83
- name: 'Uses auto-resolved provision mock',
120
+ name: 'Uses auto-resolved resource mock',
84
121
  },
85
122
  ],
86
123
  input: z.object({}),
87
124
  output: z.object({ source: z.string() }),
88
- provisions: [mockDbProvision],
125
+ resources: [mockDbResource],
89
126
  });
90
127
 
91
- const explicitOverrideTrail = trail('provision.override', {
128
+ const explicitOverrideTrail = trail('resource.override', {
92
129
  blaze: (_input, ctx) =>
93
- Result.ok({ source: mockDbProvision.from(ctx).source }),
94
- description: 'Trail whose provision mock can be overridden explicitly',
130
+ Result.ok({ source: mockDbResource.from(ctx).source }),
131
+ description: 'Trail whose resource mock can be overridden explicitly',
95
132
  examples: [
96
133
  {
97
134
  expected: { source: 'override' },
98
135
  input: {},
99
- name: 'Explicit provision override wins over mock factory',
136
+ name: 'Explicit resource override wins over mock factory',
100
137
  },
101
138
  ],
102
139
  input: z.object({}),
103
140
  output: z.object({ source: z.string() }),
104
- provisions: [mockDbProvision],
141
+ resources: [mockDbResource],
105
142
  });
106
143
 
107
144
  const transformedInputTrail = trail('example.transformed', {
@@ -120,62 +157,62 @@ const transformedInputTrail = trail('example.transformed', {
120
157
  output: z.object({ value: z.number() }),
121
158
  });
122
159
 
123
- const ctxOverrideTrail = trail('provision.ctx-override', {
160
+ const ctxOverrideTrail = trail('resource.ctx-override', {
124
161
  blaze: (_input, ctx) =>
125
- Result.ok({ source: mockDbProvision.from(ctx).source }),
126
- description: 'Trail whose provision mock can be overridden by ctx.extensions',
162
+ Result.ok({ source: mockDbResource.from(ctx).source }),
163
+ description: 'Trail whose resource mock can be overridden by ctx.extensions',
127
164
  examples: [
128
165
  {
129
166
  expected: { source: 'ctx' },
130
167
  input: {},
131
- name: 'Context extensions beat auto-resolved mock provisions',
168
+ name: 'Context extensions beat auto-resolved mock resources',
132
169
  },
133
170
  ],
134
171
  input: z.object({}),
135
172
  output: z.object({ source: z.string() }),
136
- provisions: [mockDbProvision],
173
+ resources: [mockDbResource],
137
174
  });
138
175
 
139
- const undeclaredDbProvision = provision('db.undeclared.examples', {
176
+ const undeclaredDbResource = resource('db.undeclared.examples', {
140
177
  create: () => Result.ok({ source: 'factory' }),
141
178
  mock: () => ({ source: 'mock' }),
142
179
  });
143
180
 
144
- const undeclaredProvisionTrail = trail('provision.undeclared.examples', {
181
+ const undeclaredResourceTrail = trail('resource.undeclared.examples', {
145
182
  blaze: (_input, ctx) =>
146
- Result.ok({ source: undeclaredDbProvision.from(ctx).source }),
147
- description: 'Trail that uses a provision without declaring it',
183
+ Result.ok({ source: undeclaredDbResource.from(ctx).source }),
184
+ description: 'Trail that uses a resource without declaring it',
148
185
  examples: [
149
186
  {
150
187
  error: 'InternalError',
151
188
  input: {},
152
- name: 'Undeclared provisions stay unavailable during example execution',
189
+ name: 'Undeclared resources stay unavailable during example execution',
153
190
  },
154
191
  ],
155
192
  input: z.object({}),
156
193
  output: z.object({ source: z.string() }),
157
194
  });
158
- const crossDbProvision = provision('db.mock.examples.crosses', {
195
+ const crossDbResource = resource('db.mock.examples.crosses', {
159
196
  create: () => Result.ok({ source: 'factory' }),
160
197
  mock: () => ({ source: 'mock' }),
161
198
  });
162
199
 
163
- const crossLeafTrail = trail('provision.crosses.leaf', {
200
+ const crossLeafTrail = trail('resource.crosses.leaf', {
164
201
  blaze: (_input, ctx) =>
165
- Result.ok({ childSource: crossDbProvision.from(ctx).source }),
166
- description: 'Leaf trail that resolves a provision inside a cross chain',
202
+ Result.ok({ childSource: crossDbResource.from(ctx).source }),
203
+ description: 'Leaf trail that resolves a resource inside a cross chain',
167
204
  input: z.object({}),
168
205
  output: z.object({ childSource: z.string() }),
169
- provisions: [crossDbProvision],
206
+ resources: [crossDbResource],
170
207
  });
171
208
 
172
- const crossRootTrail = trail('provision.crosses.root', {
209
+ const crossRootTrail = trail('resource.crosses.root', {
173
210
  blaze: async (_input, ctx) => {
174
211
  if (!ctx.cross) {
175
212
  return Result.err(new Error('cross not available'));
176
213
  }
177
214
  const childResult = await ctx.cross<{ childSource: string }>(
178
- 'provision.crosses.leaf',
215
+ 'resource.crosses.leaf',
179
216
  {}
180
217
  );
181
218
  if (childResult.isErr()) {
@@ -183,21 +220,21 @@ const crossRootTrail = trail('provision.crosses.root', {
183
220
  }
184
221
  return Result.ok({
185
222
  childSource: childResult.value.childSource,
186
- rootSource: crossDbProvision.from(ctx).source,
223
+ rootSource: crossDbResource.from(ctx).source,
187
224
  });
188
225
  },
189
- crosses: ['provision.crosses.leaf'],
190
- description: 'Root trail that crosses a child trail using provisions',
226
+ crosses: ['resource.crosses.leaf'],
227
+ description: 'Root trail that crosses a child trail using resources',
191
228
  examples: [
192
229
  {
193
230
  expected: { childSource: 'mock', rootSource: 'mock' },
194
231
  input: {},
195
- name: 'Propagates provision mocks through cross execution',
232
+ name: 'Propagates resource mocks through cross execution',
196
233
  },
197
234
  ],
198
235
  input: z.object({}),
199
236
  output: z.object({ childSource: z.string(), rootSource: z.string() }),
200
- provisions: [crossDbProvision],
237
+ resources: [crossDbResource],
201
238
  });
202
239
 
203
240
  // ---------------------------------------------------------------------------
@@ -262,6 +299,7 @@ describe('testExamples', () => {
262
299
  greetTrail,
263
300
  noExamplesTrail,
264
301
  searchTrail,
302
+ taxonomyErrorTrail,
265
303
  } as Record<string, unknown>)
266
304
  );
267
305
 
@@ -296,25 +334,25 @@ describe('testExamples skips trails with no examples', () => {
296
334
  });
297
335
  });
298
336
 
299
- describe('testExamples provision mocks', () => {
337
+ describe('testExamples resource mocks', () => {
300
338
  // eslint-disable-next-line jest/require-hook
301
339
  testExamples(
302
- topo('provision-mock-app', {
303
- mockDbProvision,
304
- mockProvisionTrail,
340
+ topo('resource-mock-app', {
341
+ mockDbResource,
342
+ mockResourceTrail,
305
343
  } as Record<string, unknown>)
306
344
  );
307
345
  });
308
346
 
309
- describe('testExamples explicit provision overrides', () => {
347
+ describe('testExamples explicit resource overrides', () => {
310
348
  // eslint-disable-next-line jest/require-hook
311
349
  testExamples(
312
- topo('provision-override-app', {
350
+ topo('resource-override-app', {
313
351
  explicitOverrideTrail,
314
- mockDbProvision,
352
+ mockDbResource,
315
353
  } as Record<string, unknown>),
316
354
  {
317
- provisions: { 'db.mock.examples': { source: 'override' } },
355
+ resources: { 'db.mock.examples': { source: 'override' } },
318
356
  }
319
357
  );
320
358
  });
@@ -333,7 +371,7 @@ describe('testExamples context extension overrides', () => {
333
371
  testExamples(
334
372
  topo('ctx-override-app', {
335
373
  ctxOverrideTrail,
336
- mockDbProvision,
374
+ mockDbResource,
337
375
  } as Record<string, unknown>),
338
376
  {
339
377
  ctx: {
@@ -343,12 +381,12 @@ describe('testExamples context extension overrides', () => {
343
381
  );
344
382
  });
345
383
 
346
- describe('testExamples provision declarations', () => {
384
+ describe('testExamples resource declarations', () => {
347
385
  // eslint-disable-next-line jest/require-hook
348
386
  testExamples(
349
- topo('undeclared-provision-app', {
350
- undeclaredDbProvision,
351
- undeclaredProvisionTrail,
387
+ topo('undeclared-resource-app', {
388
+ undeclaredDbResource,
389
+ undeclaredResourceTrail,
352
390
  } as Record<string, unknown>)
353
391
  );
354
392
  });
@@ -364,11 +402,11 @@ describe('testExamples crossing coverage for trails with crossings', () => {
364
402
  );
365
403
  });
366
404
 
367
- describe('testExamples provision mocks through cross', () => {
405
+ describe('testExamples resource mocks through cross', () => {
368
406
  // eslint-disable-next-line jest/require-hook
369
407
  testExamples(
370
- topo('provision-cross-app', {
371
- crossDbProvision,
408
+ topo('resource-cross-app', {
409
+ crossDbResource,
372
410
  crossLeafTrail,
373
411
  crossRootTrail,
374
412
  } as Record<string, unknown>)
@@ -532,3 +570,67 @@ describe('testExamples auto-minting permits', () => {
532
570
  );
533
571
  });
534
572
  });
573
+
574
+ // ---------------------------------------------------------------------------
575
+ // Derived-fixture crossing coverage regression
576
+ // ---------------------------------------------------------------------------
577
+ //
578
+ // A composition trail whose only examples come from contour-derived
579
+ // fixtures must not fail crossing-coverage — derived inputs are not
580
+ // guaranteed to exercise every declared cross.
581
+
582
+ const itemContour = contour(
583
+ 'item',
584
+ {
585
+ id: z.string(),
586
+ name: z.string(),
587
+ },
588
+ {
589
+ examples: [{ id: 'abc', name: 'Widget' }],
590
+ identity: 'id',
591
+ }
592
+ );
593
+
594
+ const helperTrail = trail('derived.helper', {
595
+ blaze: (input: { id: string }) => Result.ok({ id: input.id, ok: true }),
596
+ description: 'Helper referenced by a conditional cross',
597
+ input: z.object({ id: z.string() }),
598
+ output: z.object({ id: z.string(), ok: z.boolean() }),
599
+ });
600
+
601
+ const conditionalCrossTrail = trail('derived.conditional', {
602
+ blaze: async (input: { id: string; name: string }, ctx) => {
603
+ // The conditional cross is never taken for derived fixtures because
604
+ // `shouldCross` is always false in the synthesized input. This is
605
+ // exactly the case the provenance gate exists to protect: if we
606
+ // asserted crossing coverage against derived examples, this trail
607
+ // would fail even though its declaration is accurate for authored
608
+ // use.
609
+ const { shouldCross } = input as { shouldCross?: boolean };
610
+ if (shouldCross && ctx.cross) {
611
+ const result = await ctx.cross<{ id: string; ok: boolean }>(
612
+ 'derived.helper',
613
+ { id: input.id }
614
+ );
615
+ if (result.isErr()) {
616
+ return result;
617
+ }
618
+ }
619
+ return Result.ok({ id: input.id, name: input.name });
620
+ },
621
+ contours: [itemContour],
622
+ crosses: ['derived.helper'],
623
+ description: 'Composition trail with a cross that derived fixtures skip',
624
+ input: z.object({ id: z.string(), name: z.string() }),
625
+ output: z.object({ id: z.string(), name: z.string() }),
626
+ });
627
+
628
+ describe('testExamples derived-fixture crossing coverage is gated', () => {
629
+ // eslint-disable-next-line jest/require-hook
630
+ testExamples(
631
+ topo('derived-coverage-app', {
632
+ conditionalCrossTrail,
633
+ helperTrail,
634
+ } as Record<string, unknown>)
635
+ );
636
+ });
@@ -0,0 +1,90 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+
3
+ import { resource, Result, topo, trail } from '@ontrails/core';
4
+ import { z } from 'zod';
5
+
6
+ import { createCliHarness } from '../harness-cli.js';
7
+
8
+ describe('createCliHarness', () => {
9
+ test('runs top-level commands', async () => {
10
+ const greet = trail('greet', {
11
+ blaze: (input: { name: string }) => Result.ok(`Hello, ${input.name}!`),
12
+ input: z.object({ name: z.string() }),
13
+ });
14
+ const harness = createCliHarness({
15
+ graph: topo('test-app', { greet }),
16
+ });
17
+
18
+ const result = await harness.run('greet --name Trails');
19
+
20
+ expect(result.exitCode).toBe(0);
21
+ expect(result.stdout).toContain('Hello, Trails!');
22
+ });
23
+
24
+ test('runs nested commands using the full ordered path', async () => {
25
+ const pin = trail('topo.pin', {
26
+ blaze: (input: { name: string }) => Result.ok(`Pinned ${input.name}`),
27
+ input: z.object({ name: z.string() }),
28
+ });
29
+ const harness = createCliHarness({
30
+ graph: topo('test-app', { pin }),
31
+ });
32
+
33
+ const result = await harness.run('topo pin --name before-auth');
34
+
35
+ expect(result.exitCode).toBe(0);
36
+ expect(result.stdout).toContain('Pinned before-auth');
37
+ });
38
+
39
+ test('prefers the deepest matching executable path', async () => {
40
+ const calls: string[] = [];
41
+ const topoShow = trail('topo', {
42
+ blaze: () => {
43
+ calls.push('topo');
44
+ return Result.ok('topo');
45
+ },
46
+ input: z.object({}),
47
+ });
48
+ const topoPin = trail('topo.pin', {
49
+ blaze: () => {
50
+ calls.push('topo.pin');
51
+ return Result.ok('topo.pin');
52
+ },
53
+ input: z.object({}),
54
+ });
55
+ const harness = createCliHarness({
56
+ graph: topo('test-app', { topoPin, topoShow }),
57
+ });
58
+
59
+ const child = await harness.run('topo pin');
60
+ const parent = await harness.run('topo');
61
+
62
+ expect(child.exitCode).toBe(0);
63
+ expect(parent.exitCode).toBe(0);
64
+ expect(calls).toEqual(['topo.pin', 'topo']);
65
+ });
66
+
67
+ test('threads resource overrides through CLI projection options', async () => {
68
+ const dbResource = resource('db.main', {
69
+ create: () => Result.ok({ source: 'factory' }),
70
+ });
71
+ const readResource = trail('resource.read', {
72
+ blaze: (_input, ctx) =>
73
+ Result.ok({ source: dbResource.from(ctx).source as string }),
74
+ input: z.object({}),
75
+ output: z.object({ source: z.string() }),
76
+ resources: [dbResource],
77
+ });
78
+ const harness = createCliHarness({
79
+ graph: topo('test-app', { dbResource, readResource }),
80
+ resources: {
81
+ 'db.main': { source: 'override' },
82
+ },
83
+ });
84
+
85
+ const result = await harness.run('resource read --output json');
86
+
87
+ expect(result.exitCode).toBe(0);
88
+ expect(result.stdout).toContain('"override"');
89
+ });
90
+ });
@@ -0,0 +1,37 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+
3
+ import { resource, Result, topo, trail } from '@ontrails/core';
4
+ import { deriveToolName } from '@ontrails/mcp';
5
+ import { z } from 'zod';
6
+
7
+ import { createMcpHarness } from '../harness-mcp.js';
8
+
9
+ describe('createMcpHarness', () => {
10
+ test('threads resource overrides through MCP projection options', async () => {
11
+ const dbResource = resource('db.main', {
12
+ create: () => Result.ok({ source: 'factory' }),
13
+ });
14
+ const readResource = trail('resource.read', {
15
+ blaze: (_input, ctx) =>
16
+ Result.ok({ source: dbResource.from(ctx).source as string }),
17
+ input: z.object({}),
18
+ output: z.object({ source: z.string() }),
19
+ resources: [dbResource],
20
+ });
21
+ const graph = topo('test-app', { dbResource, readResource });
22
+ const harness = createMcpHarness({
23
+ graph,
24
+ resources: {
25
+ 'db.main': { source: 'override' },
26
+ },
27
+ });
28
+
29
+ const result = await harness.callTool(
30
+ deriveToolName(graph.name, readResource.id),
31
+ {}
32
+ );
33
+
34
+ expect(result.isError).toBe(false);
35
+ expect(JSON.stringify(result.content)).toContain('override');
36
+ });
37
+ });