@ontrails/testing 0.2.0

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.
@@ -0,0 +1,109 @@
1
+ /**
2
+ * testContracts — output schema verification.
3
+ *
4
+ * For every trail that has both examples and an output schema,
5
+ * run each example and validate the Result.ok value against
6
+ * the declared schema.
7
+ */
8
+
9
+ import { describe, test } from 'bun:test';
10
+
11
+ import type { Topo, TrailExample, Trail, TrailContext } from '@ontrails/core';
12
+ import { executeTrail, formatZodIssues, validateInput } from '@ontrails/core';
13
+ import type { z } from 'zod';
14
+
15
+ import { expectOk } from './assertions.js';
16
+ import {
17
+ mergeResourceOverrides,
18
+ mergeTestContext,
19
+ normalizeTestExecutionOptions,
20
+ createMockResources,
21
+ } from './context.js';
22
+ import type { TestExecutionOptions } from './context.js';
23
+ import type { TrailExampleTarget } from './effective-examples.js';
24
+ import { deriveTrailExampleTargets } from './effective-examples.js';
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Helpers
28
+ // ---------------------------------------------------------------------------
29
+
30
+ const validateOutputSchema = (
31
+ outputSchema: z.ZodType,
32
+ value: unknown,
33
+ trailId: string,
34
+ exampleName: string
35
+ ): void => {
36
+ const parsed = outputSchema.safeParse(value);
37
+ if (!parsed.success) {
38
+ const issues = formatZodIssues(parsed.error.issues);
39
+ throw new Error(
40
+ `Output schema violation for trail "${trailId}", example "${exampleName}":\n${issues.map((i) => ` - ${i}`).join('\n')}\n\nActual output: ${JSON.stringify(value, null, 2)}`
41
+ );
42
+ }
43
+ };
44
+
45
+ type ContractExampleTarget = TrailExampleTarget & {
46
+ readonly output: z.ZodType;
47
+ };
48
+
49
+ const hasContractExamples = (
50
+ target: TrailExampleTarget
51
+ ): target is ContractExampleTarget =>
52
+ target.output !== undefined && target.examples.length > 0;
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // testContracts
56
+ // ---------------------------------------------------------------------------
57
+
58
+ /**
59
+ * Verify that every successful trail result matches its declared
60
+ * output schema. Catches output-schema drift.
61
+ *
62
+ * Trails without output schemas or examples are skipped.
63
+ */
64
+ export const testContracts = (
65
+ app: Topo,
66
+ ctxOrFactory?:
67
+ | Partial<TrailContext>
68
+ | TestExecutionOptions
69
+ | (() => Partial<TrailContext> | TestExecutionOptions)
70
+ ): void => {
71
+ const resolveInput =
72
+ typeof ctxOrFactory === 'function' ? ctxOrFactory : () => ctxOrFactory;
73
+ const allEntries = (app.list() as Trail<unknown, unknown, unknown>[])
74
+ .flatMap(deriveTrailExampleTargets)
75
+ .filter(hasContractExamples);
76
+
77
+ describe('contracts', () => {
78
+ describe.each(allEntries)('$id', (t) => {
79
+ const { examples, output: outputSchema } = t;
80
+ const successExamples = examples.filter((e) => e.error === undefined);
81
+
82
+ test.each(successExamples)(
83
+ 'contract: $name',
84
+ async (example: TrailExample<unknown, unknown>) => {
85
+ const resolved = normalizeTestExecutionOptions(resolveInput());
86
+ const resources = mergeResourceOverrides(
87
+ await createMockResources(app),
88
+ resolved.ctx,
89
+ resolved.resources
90
+ );
91
+ const testCtx = mergeTestContext(resolved.ctx);
92
+
93
+ const validated = validateInput(t.input, example.input);
94
+ expectOk(validated);
95
+
96
+ const result = await executeTrail(t.trail, example.input, {
97
+ ctx: testCtx,
98
+ resources,
99
+ topo: app,
100
+ ...(t.version === undefined ? {} : { version: t.version }),
101
+ });
102
+ const resultValue = expectOk(result);
103
+
104
+ validateOutputSchema(outputSchema, resultValue, t.id, example.name);
105
+ }
106
+ );
107
+ });
108
+ });
109
+ };
package/src/detours.ts ADDED
@@ -0,0 +1,181 @@
1
+ /**
2
+ * testDetours — validate the live detour contract for every trail.
3
+ *
4
+ * Pure structural validation. No implementation or detour recovery execution needed.
5
+ */
6
+
7
+ import { describe, test } from 'bun:test';
8
+
9
+ import { TrailsError } from '@ontrails/core';
10
+ import type { Topo, Trail } from '@ontrails/core';
11
+
12
+ interface RuntimeDetour {
13
+ readonly on?: unknown;
14
+ readonly recover?: unknown;
15
+ }
16
+
17
+ const isErrorConstructor = (
18
+ value: unknown
19
+ ): value is abstract new (...args: never[]) => Error => {
20
+ if (typeof value !== 'function') {
21
+ return false;
22
+ }
23
+
24
+ const { prototype } = value as { prototype?: unknown };
25
+ return prototype instanceof Error;
26
+ };
27
+
28
+ const detourLabel = (trailId: string, index: number, detour: RuntimeDetour) => {
29
+ if (typeof detour.on === 'function') {
30
+ const { name } = detour.on as { name?: unknown };
31
+ if (typeof name === 'string' && name.length > 0) {
32
+ return `${trailId} detour[${index}] on ${name}`;
33
+ }
34
+ }
35
+
36
+ return `${trailId} detour[${index}]`;
37
+ };
38
+
39
+ const assertValidOn = (
40
+ trailId: string,
41
+ index: number,
42
+ detour: RuntimeDetour
43
+ ): void => {
44
+ if (isErrorConstructor(detour.on)) {
45
+ return;
46
+ }
47
+
48
+ throw new Error(
49
+ `${detourLabel(trailId, index, detour)} must declare a real error constructor in on:`
50
+ );
51
+ };
52
+
53
+ const assertCallableRecover = (
54
+ trailId: string,
55
+ index: number,
56
+ detour: RuntimeDetour
57
+ ): void => {
58
+ if (typeof detour.recover === 'function') {
59
+ return;
60
+ }
61
+
62
+ throw new Error(
63
+ `${detourLabel(trailId, index, detour)} must declare a callable recover function`
64
+ );
65
+ };
66
+
67
+ const sameOrSubtype = (
68
+ candidate: abstract new (...args: never[]) => Error,
69
+ ancestor: abstract new (...args: never[]) => Error
70
+ ): boolean => {
71
+ if (candidate === ancestor) {
72
+ return true;
73
+ }
74
+
75
+ let current = Object.getPrototypeOf(candidate.prototype);
76
+ while (current && typeof current === 'object') {
77
+ const ctor = (current as { constructor?: unknown }).constructor;
78
+ if (ctor === ancestor) {
79
+ return true;
80
+ }
81
+ current = Object.getPrototypeOf(current);
82
+ }
83
+
84
+ return false;
85
+ };
86
+
87
+ const getShadowingDetour = (
88
+ detours: readonly RuntimeDetour[],
89
+ index: number
90
+ ):
91
+ | {
92
+ readonly index: number;
93
+ readonly on: abstract new (...args: never[]) => Error;
94
+ }
95
+ | undefined => {
96
+ const detour = detours[index];
97
+ if (!detour || !isErrorConstructor(detour.on)) {
98
+ return undefined;
99
+ }
100
+
101
+ for (let previousIndex = 0; previousIndex < index; previousIndex += 1) {
102
+ const previous = detours[previousIndex];
103
+ if (!previous || !isErrorConstructor(previous.on)) {
104
+ continue;
105
+ }
106
+
107
+ if (sameOrSubtype(detour.on, previous.on)) {
108
+ return { index: previousIndex, on: previous.on };
109
+ }
110
+ }
111
+
112
+ return undefined;
113
+ };
114
+
115
+ const assertNotShadowed = (
116
+ trailId: string,
117
+ detours: readonly RuntimeDetour[],
118
+ index: number
119
+ ): void => {
120
+ const detour = detours[index];
121
+ if (!detour || !isErrorConstructor(detour.on)) {
122
+ return;
123
+ }
124
+
125
+ const shadowing = getShadowingDetour(detours, index);
126
+ if (!shadowing) {
127
+ return;
128
+ }
129
+
130
+ const previousName = shadowing.on.name || TrailsError.name;
131
+ const currentName = detour.on.name || TrailsError.name;
132
+ throw new Error(
133
+ `${trailId} detour[${index}] on ${currentName} is shadowed by earlier detour[${shadowing.index}] on ${previousName}`
134
+ );
135
+ };
136
+
137
+ /**
138
+ * Verify that every trail's detours match the live runtime contract:
139
+ * `on` must be an error constructor, `recover` must be callable, and
140
+ * later detours must not be shadowed by earlier broader `on` types.
141
+ */
142
+ export const testDetours = (app: Topo): void => {
143
+ const trailEntries = [...app.trails];
144
+
145
+ describe('detours', () => {
146
+ describe.each(trailEntries)('%s', (_id, trailDef) => {
147
+ const trail = trailDef as Trail<unknown, unknown, unknown>;
148
+
149
+ if (trail.detours.length === 0) {
150
+ return;
151
+ }
152
+
153
+ const detourCases = trail.detours.map((detour, index) => ({
154
+ detour,
155
+ index,
156
+ trailId: trail.id,
157
+ }));
158
+
159
+ test.each(detourCases)(
160
+ '$trailId detour[$index] uses an error constructor',
161
+ ({ detour, index, trailId }) => {
162
+ assertValidOn(trailId, index, detour);
163
+ }
164
+ );
165
+
166
+ test.each(detourCases)(
167
+ '$trailId detour[$index] provides a callable recover',
168
+ ({ detour, index, trailId }) => {
169
+ assertCallableRecover(trailId, index, detour);
170
+ }
171
+ );
172
+
173
+ test.each(detourCases)(
174
+ '$trailId detour[$index] is not shadowed by an earlier detour',
175
+ ({ index, trailId }) => {
176
+ assertNotShadowed(trailId, trail.detours, index);
177
+ }
178
+ );
179
+ });
180
+ });
181
+ };
@@ -0,0 +1,408 @@
1
+ import type { AnyEntity, Trail, TrailExample } from '@ontrails/core';
2
+ import {
3
+ getEntityReferences,
4
+ getTrailVersionEntryKind,
5
+ isArchivedTrailVersionEntry,
6
+ } from '@ontrails/core';
7
+ import { z } from 'zod';
8
+
9
+ type ExampleRecord = Readonly<Record<string, unknown>>;
10
+
11
+ export interface TrailExampleTarget {
12
+ readonly composes: readonly string[];
13
+ readonly current: boolean;
14
+ readonly examples: readonly TrailExample<unknown, unknown>[];
15
+ readonly id: string;
16
+ readonly input: Trail<unknown, unknown, unknown>['input'];
17
+ readonly output: Trail<unknown, unknown, unknown>['output'];
18
+ readonly trail: Trail<unknown, unknown, unknown>;
19
+ readonly version?: number | undefined;
20
+ }
21
+
22
+ const normalizeComposeRef = (value: string | { readonly id: string }): string =>
23
+ typeof value === 'string' ? value : value.id;
24
+
25
+ /**
26
+ * Tracks examples that `deriveTrailExamples` synthesizes from entity
27
+ * fixtures. Authored examples are passed through untouched and never
28
+ * appear here, so consumers can distinguish the two by identity.
29
+ *
30
+ * Exposed via `isDerivedExample` so downstream testing helpers (e.g.
31
+ * `testExamples` composing coverage) can relax invariants that only make
32
+ * sense for authored inputs.
33
+ */
34
+ const derivedExamples = new WeakSet<TrailExample<unknown, unknown>>();
35
+
36
+ /**
37
+ * Returns `true` if the given example was synthesized from entity fixtures
38
+ * by `deriveTrailExamples`, `false` if it was authored on the trail.
39
+ */
40
+ export const isDerivedExample = (
41
+ example: TrailExample<unknown, unknown>
42
+ ): boolean => derivedExamples.has(example);
43
+
44
+ interface EntityFixture {
45
+ readonly entity: AnyEntity;
46
+ readonly example: ExampleRecord;
47
+ readonly index: number;
48
+ }
49
+
50
+ const capitalize = (value: string): string =>
51
+ value.length === 0 ? value : value.slice(0, 1).toUpperCase() + value.slice(1);
52
+
53
+ const collectReferenceMap = (
54
+ entities: readonly AnyEntity[]
55
+ ): ReadonlyMap<string, ReturnType<typeof getEntityReferences>> => {
56
+ const entityNames = new Set(entities.map((entity) => entity.name));
57
+
58
+ return new Map(
59
+ entities.map((entity) => [
60
+ entity.name,
61
+ getEntityReferences(entity).filter((reference) =>
62
+ entityNames.has(reference.entity)
63
+ ),
64
+ ])
65
+ );
66
+ };
67
+
68
+ const getIdentityValue = (fixture: EntityFixture): unknown =>
69
+ fixture.example[fixture.entity.identity];
70
+
71
+ const candidateMatchesSelectedReference = (
72
+ candidate: EntityFixture,
73
+ target: EntityFixture,
74
+ reference: ReturnType<typeof getEntityReferences>[number]
75
+ ): boolean =>
76
+ Object.is(candidate.example[reference.field], getIdentityValue(target));
77
+
78
+ const selectedMatchesCandidateReference = (
79
+ fixture: EntityFixture,
80
+ candidate: EntityFixture,
81
+ reference: ReturnType<typeof getEntityReferences>[number]
82
+ ): boolean =>
83
+ Object.is(fixture.example[reference.field], getIdentityValue(candidate));
84
+
85
+ const matchesCandidateReferences = (
86
+ candidate: EntityFixture,
87
+ selected: readonly EntityFixture[],
88
+ referencesByEntity: ReadonlyMap<
89
+ string,
90
+ ReturnType<typeof getEntityReferences>
91
+ >
92
+ ): boolean => {
93
+ const candidateReferences =
94
+ referencesByEntity.get(candidate.entity.name) ?? [];
95
+
96
+ for (const reference of candidateReferences) {
97
+ const target = selected.find(
98
+ (fixture) => fixture.entity.name === reference.entity
99
+ );
100
+ if (target === undefined) {
101
+ continue;
102
+ }
103
+ if (!candidateMatchesSelectedReference(candidate, target, reference)) {
104
+ return false;
105
+ }
106
+ }
107
+
108
+ return true;
109
+ };
110
+
111
+ const matchesSelectedReferences = (
112
+ candidate: EntityFixture,
113
+ selected: readonly EntityFixture[],
114
+ referencesByEntity: ReadonlyMap<
115
+ string,
116
+ ReturnType<typeof getEntityReferences>
117
+ >
118
+ ): boolean => {
119
+ for (const fixture of selected) {
120
+ const fixtureReferences = referencesByEntity.get(fixture.entity.name) ?? [];
121
+ for (const reference of fixtureReferences) {
122
+ if (reference.entity !== candidate.entity.name) {
123
+ continue;
124
+ }
125
+ if (!selectedMatchesCandidateReference(fixture, candidate, reference)) {
126
+ return false;
127
+ }
128
+ }
129
+ }
130
+
131
+ return true;
132
+ };
133
+
134
+ const matchesKnownReferences = (
135
+ candidate: EntityFixture,
136
+ selected: readonly EntityFixture[],
137
+ referencesByEntity: ReadonlyMap<
138
+ string,
139
+ ReturnType<typeof getEntityReferences>
140
+ >
141
+ ): boolean =>
142
+ matchesCandidateReferences(candidate, selected, referencesByEntity) &&
143
+ matchesSelectedReferences(candidate, selected, referencesByEntity);
144
+
145
+ const selectEntityFixtures = (
146
+ entities: readonly AnyEntity[],
147
+ referencesByEntity: ReadonlyMap<
148
+ string,
149
+ ReturnType<typeof getEntityReferences>
150
+ >,
151
+ index = 0,
152
+ selected: readonly EntityFixture[] = []
153
+ ): readonly (readonly EntityFixture[])[] => {
154
+ const entity = entities[index];
155
+ if (entity === undefined) {
156
+ return [selected];
157
+ }
158
+
159
+ const examples = entity.examples ?? [];
160
+ const matchingFixtures = examples.flatMap((example, exampleIndex) => {
161
+ const fixture = {
162
+ entity,
163
+ example: example as ExampleRecord,
164
+ index: exampleIndex,
165
+ } satisfies EntityFixture;
166
+
167
+ if (!matchesKnownReferences(fixture, selected, referencesByEntity)) {
168
+ return [];
169
+ }
170
+
171
+ return selectEntityFixtures(entities, referencesByEntity, index + 1, [
172
+ ...selected,
173
+ fixture,
174
+ ]);
175
+ });
176
+
177
+ return matchingFixtures;
178
+ };
179
+
180
+ /**
181
+ * Merge selected entity fixtures into a single candidate input object.
182
+ *
183
+ * The resulting record contains:
184
+ * - `<entity>`: the full fixture payload keyed by entity name.
185
+ * - `<entity><Identity>`: the fixture's identity value on a prefixed key.
186
+ * - `<entity><Field>`: every fixture field on a prefixed key.
187
+ * - Unqualified `<field>` keys: first-write-wins across entities.
188
+ *
189
+ * The first-write-wins behaviour on unqualified keys is intentional but can
190
+ * silently drop a later entity's value when two entities share a field name
191
+ * (e.g. both declare `id`). The prefixed aliases above are unambiguous and
192
+ * always written, so schemas that consume the prefixed form are unaffected;
193
+ * schemas that rely on the bare field name should disambiguate via the
194
+ * prefixed alias instead.
195
+ */
196
+ const buildDerivedInput = (
197
+ fixtures: readonly EntityFixture[]
198
+ ): Record<string, unknown> => {
199
+ const candidate: Record<string, unknown> = {};
200
+
201
+ for (const fixture of fixtures) {
202
+ candidate[fixture.entity.name] = fixture.example;
203
+ candidate[`${fixture.entity.name}${capitalize(fixture.entity.identity)}`] =
204
+ getIdentityValue(fixture);
205
+
206
+ for (const [field, value] of Object.entries(fixture.example)) {
207
+ if (!Object.hasOwn(candidate, field)) {
208
+ candidate[field] = value;
209
+ }
210
+
211
+ candidate[`${fixture.entity.name}${capitalize(field)}`] = value;
212
+ }
213
+ }
214
+
215
+ return candidate;
216
+ };
217
+
218
+ /**
219
+ * Derive the merged candidate input down to keys the trail's input schema
220
+ * knows about.
221
+ *
222
+ * `buildDerivedInput` emits synthesized prefixed aliases (e.g. `userEmail`)
223
+ * alongside bare field names. Strict schemas (`z.object(...).strict()`)
224
+ * reject any unknown key, which means an otherwise valid derived fixture
225
+ * would silently fail `safeParse` just because of the synthesized aliases.
226
+ * When the input is a `ZodObject`, trim the candidate to its declared keys
227
+ * before validation. Non-object inputs pass through unchanged — they are
228
+ * validated as-is and can decide for themselves.
229
+ */
230
+ const deriveInputForSchema = (
231
+ inputSchema: Trail<unknown, unknown, unknown>['input'],
232
+ candidate: Record<string, unknown>
233
+ ): Record<string, unknown> => {
234
+ if (!(inputSchema instanceof z.ZodObject)) {
235
+ return candidate;
236
+ }
237
+
238
+ const known = Object.keys(inputSchema.shape);
239
+ const derived: Record<string, unknown> = {};
240
+ for (const key of known) {
241
+ if (Object.hasOwn(candidate, key)) {
242
+ derived[key] = candidate[key];
243
+ }
244
+ }
245
+ return derived;
246
+ };
247
+
248
+ /**
249
+ * Derive an expected output value from the selected entity fixtures when
250
+ * exactly one fixture's payload satisfies the trail's output schema.
251
+ *
252
+ * Returns `undefined` when the trail has no output schema, when no fixture
253
+ * matches, or when more than one matches — callers should then leave the
254
+ * derived example without an `expected` and fall back to schema-only
255
+ * validation. We intentionally do **not** infer `expected` from the merged
256
+ * candidate input: input and output schemas frequently overlap structurally
257
+ * but represent different semantics, so inferring from the input would
258
+ * produce false deep-equality failures.
259
+ */
260
+ const deriveExpectedValue = (
261
+ trail: Trail<unknown, unknown, unknown>,
262
+ fixtures: readonly EntityFixture[]
263
+ ): unknown => {
264
+ if (trail.output === undefined) {
265
+ return undefined;
266
+ }
267
+
268
+ const outputSchema = trail.output;
269
+ const entityMatches = fixtures
270
+ .map((fixture) => outputSchema.safeParse(fixture.example))
271
+ .filter((candidate) => candidate.success);
272
+
273
+ if (entityMatches.length !== 1) {
274
+ return undefined;
275
+ }
276
+
277
+ const [singleMatch] = entityMatches;
278
+ if (singleMatch === undefined) {
279
+ return undefined;
280
+ }
281
+ return singleMatch.data;
282
+ };
283
+
284
+ const formatFixtureName = (
285
+ fixtures: readonly EntityFixture[],
286
+ index: number
287
+ ): string => {
288
+ const label = fixtures
289
+ .map((fixture) => {
290
+ const identity = getIdentityValue(fixture);
291
+ const fallback = fixture.index + 1;
292
+ return `${fixture.entity.name}:${String(identity ?? fallback)}`;
293
+ })
294
+ .join(', ');
295
+
296
+ return label.length > 0
297
+ ? `Derived fixture ${index + 1} (${label})`
298
+ : `Derived fixture ${index + 1}`;
299
+ };
300
+
301
+ /**
302
+ * Prefer authored trail examples and fall back to entity-derived fixtures.
303
+ *
304
+ * Examples returned by this helper come from one of two provenances:
305
+ * - **Authored.** When `trail.examples` is non-empty, its entries are
306
+ * returned verbatim. These are the developer's stated intent and carry
307
+ * full invariants — including composing-coverage assertions in
308
+ * `testExamples`.
309
+ * - **Derived.** When there are no authored examples but the trail has
310
+ * entities with examples, candidate inputs are synthesized from entity
311
+ * fixtures and validated against `trail.input`. These are opportunistic
312
+ * coverage that exists to let `testAll(app)` exercise entity-backed
313
+ * trails without per-test setup; they are not guaranteed to exercise
314
+ * every composition branch, so consumers should relax invariants that
315
+ * only make sense for authored inputs (see `isDerivedExample`).
316
+ *
317
+ * Entity examples stay as the raw input payload so Trails validation /
318
+ * transforms still happen exactly once inside the normal test execution
319
+ * path. Derived examples are additionally tagged via a module-level
320
+ * `WeakSet` so consumers can detect them without widening the public
321
+ * `TrailExample` shape.
322
+ */
323
+ export const deriveTrailExamples = (
324
+ trail: Trail<unknown, unknown, unknown>
325
+ ): readonly TrailExample<unknown, unknown>[] => {
326
+ if (trail.examples !== undefined && trail.examples.length > 0) {
327
+ return trail.examples;
328
+ }
329
+
330
+ if (trail.entities.length === 0) {
331
+ return [];
332
+ }
333
+
334
+ if (
335
+ trail.entities.some(
336
+ (entity) => entity.examples === undefined || entity.examples.length === 0
337
+ )
338
+ ) {
339
+ return [];
340
+ }
341
+
342
+ const referencesByEntity = collectReferenceMap(trail.entities);
343
+ const fixtureSets = selectEntityFixtures(trail.entities, referencesByEntity);
344
+
345
+ return fixtureSets.flatMap((fixtures, index) => {
346
+ const merged = buildDerivedInput(fixtures);
347
+ const input = deriveInputForSchema(trail.input, merged);
348
+ const validated = trail.input.safeParse(input);
349
+ if (!validated.success) {
350
+ return [];
351
+ }
352
+
353
+ const expected = deriveExpectedValue(trail, fixtures);
354
+ const derived: TrailExample<unknown, unknown> = {
355
+ ...(expected === undefined ? {} : { expected }),
356
+ input,
357
+ name: formatFixtureName(fixtures, index),
358
+ };
359
+ derivedExamples.add(derived);
360
+ return [derived];
361
+ });
362
+ };
363
+
364
+ export const deriveTrailExampleTargets = (
365
+ trail: Trail<unknown, unknown, unknown>
366
+ ): readonly TrailExampleTarget[] => {
367
+ const targets: TrailExampleTarget[] = [];
368
+ const currentExamples = deriveTrailExamples(trail);
369
+ if (currentExamples.length > 0) {
370
+ targets.push({
371
+ composes: trail.composes,
372
+ current: true,
373
+ examples: currentExamples,
374
+ id: trail.id,
375
+ input: trail.input,
376
+ output: trail.output,
377
+ trail,
378
+ });
379
+ }
380
+
381
+ for (const [rawVersion, entry] of Object.entries(
382
+ trail.versions ?? {}
383
+ ).toSorted(([left], [right]) => Number(left) - Number(right))) {
384
+ if (isArchivedTrailVersionEntry(entry)) {
385
+ continue;
386
+ }
387
+ const examples = entry.examples ?? [];
388
+ if (examples.length === 0) {
389
+ continue;
390
+ }
391
+ const kind = getTrailVersionEntryKind(entry);
392
+ targets.push({
393
+ composes:
394
+ kind === 'fork'
395
+ ? (entry.composes ?? []).map(normalizeComposeRef)
396
+ : trail.composes,
397
+ current: false,
398
+ examples,
399
+ id: `${trail.id}@${rawVersion}`,
400
+ input: entry.input,
401
+ output: entry.output,
402
+ trail,
403
+ version: Number(rawVersion),
404
+ });
405
+ }
406
+
407
+ return targets;
408
+ };