@auto-engineer/narrative 0.13.0 → 0.13.1

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 (89) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +11 -0
  3. package/dist/src/commands/export-schema-runner.js +1 -1
  4. package/dist/src/commands/export-schema-runner.js.map +1 -1
  5. package/dist/src/fluent-builder.js +3 -3
  6. package/dist/src/fluent-builder.js.map +1 -1
  7. package/dist/src/getNarratives.specs.js +149 -153
  8. package/dist/src/getNarratives.specs.js.map +1 -1
  9. package/dist/src/id/addAutoIds.d.ts.map +1 -1
  10. package/dist/src/id/addAutoIds.js +23 -10
  11. package/dist/src/id/addAutoIds.js.map +1 -1
  12. package/dist/src/id/addAutoIds.specs.js +54 -45
  13. package/dist/src/id/addAutoIds.specs.js.map +1 -1
  14. package/dist/src/id/hasAllIds.d.ts.map +1 -1
  15. package/dist/src/id/hasAllIds.js +8 -3
  16. package/dist/src/id/hasAllIds.js.map +1 -1
  17. package/dist/src/id/hasAllIds.specs.js +142 -215
  18. package/dist/src/id/hasAllIds.specs.js.map +1 -1
  19. package/dist/src/index.d.ts +6 -8
  20. package/dist/src/index.d.ts.map +1 -1
  21. package/dist/src/index.js +3 -3
  22. package/dist/src/index.js.map +1 -1
  23. package/dist/src/loader/graph.d.ts.map +1 -1
  24. package/dist/src/loader/graph.js +13 -6
  25. package/dist/src/loader/graph.js.map +1 -1
  26. package/dist/src/loader/ts-utils.d.ts +1 -0
  27. package/dist/src/loader/ts-utils.d.ts.map +1 -1
  28. package/dist/src/loader/ts-utils.js +95 -16
  29. package/dist/src/loader/ts-utils.js.map +1 -1
  30. package/dist/src/model-to-narrative.specs.js +531 -449
  31. package/dist/src/model-to-narrative.specs.js.map +1 -1
  32. package/dist/src/narrative-context.d.ts +8 -8
  33. package/dist/src/narrative-context.d.ts.map +1 -1
  34. package/dist/src/narrative-context.js +111 -301
  35. package/dist/src/narrative-context.js.map +1 -1
  36. package/dist/src/narrative-context.specs.js +15 -55
  37. package/dist/src/narrative-context.specs.js.map +1 -1
  38. package/dist/src/narrative.d.ts +19 -22
  39. package/dist/src/narrative.d.ts.map +1 -1
  40. package/dist/src/narrative.js +42 -71
  41. package/dist/src/narrative.js.map +1 -1
  42. package/dist/src/samples/test-with-ids.narrative.js +13 -29
  43. package/dist/src/samples/test-with-ids.narrative.js.map +1 -1
  44. package/dist/src/schema.d.ts +2704 -8293
  45. package/dist/src/schema.d.ts.map +1 -1
  46. package/dist/src/schema.js +26 -47
  47. package/dist/src/schema.js.map +1 -1
  48. package/dist/src/slice-builder.js +3 -3
  49. package/dist/src/slice-builder.js.map +1 -1
  50. package/dist/src/transformers/model-to-narrative/generators/flow.d.ts.map +1 -1
  51. package/dist/src/transformers/model-to-narrative/generators/flow.js +118 -74
  52. package/dist/src/transformers/model-to-narrative/generators/flow.js.map +1 -1
  53. package/dist/src/transformers/model-to-narrative/generators/gwt.d.ts +9 -1
  54. package/dist/src/transformers/model-to-narrative/generators/gwt.d.ts.map +1 -1
  55. package/dist/src/transformers/model-to-narrative/generators/gwt.js +112 -112
  56. package/dist/src/transformers/model-to-narrative/generators/gwt.js.map +1 -1
  57. package/dist/src/transformers/model-to-narrative/generators/imports.d.ts +1 -1
  58. package/dist/src/transformers/model-to-narrative/generators/imports.d.ts.map +1 -1
  59. package/dist/src/transformers/model-to-narrative/generators/imports.js +13 -9
  60. package/dist/src/transformers/model-to-narrative/generators/imports.js.map +1 -1
  61. package/dist/src/transformers/narrative-to-model/index.d.ts.map +1 -1
  62. package/dist/src/transformers/narrative-to-model/index.js +50 -23
  63. package/dist/src/transformers/narrative-to-model/index.js.map +1 -1
  64. package/dist/src/transformers/narrative-to-model/type-inference.specs.js +100 -90
  65. package/dist/src/transformers/narrative-to-model/type-inference.specs.js.map +1 -1
  66. package/dist/tsconfig.tsbuildinfo +1 -1
  67. package/package.json +5 -5
  68. package/src/commands/export-schema-runner.ts +3 -1
  69. package/src/fluent-builder.ts +3 -3
  70. package/src/getNarratives.specs.ts +168 -176
  71. package/src/id/addAutoIds.specs.ts +54 -47
  72. package/src/id/addAutoIds.ts +28 -11
  73. package/src/id/hasAllIds.specs.ts +147 -245
  74. package/src/id/hasAllIds.ts +11 -4
  75. package/src/index.ts +9 -12
  76. package/src/loader/graph.ts +23 -6
  77. package/src/loader/ts-utils.ts +169 -26
  78. package/src/model-to-narrative.specs.ts +531 -449
  79. package/src/narrative-context.specs.ts +73 -116
  80. package/src/narrative-context.ts +127 -374
  81. package/src/narrative.ts +70 -120
  82. package/src/samples/test-with-ids.narrative.ts +23 -31
  83. package/src/schema.ts +33 -52
  84. package/src/slice-builder.ts +3 -3
  85. package/src/transformers/model-to-narrative/generators/flow.ts +191 -85
  86. package/src/transformers/model-to-narrative/generators/gwt.ts +195 -178
  87. package/src/transformers/model-to-narrative/generators/imports.ts +13 -9
  88. package/src/transformers/narrative-to-model/index.ts +87 -26
  89. package/src/transformers/narrative-to-model/type-inference.specs.ts +100 -90
@@ -1,24 +1,20 @@
1
1
  import createDebug from 'debug';
2
2
  import type { DataSinkItem, DataSourceItem, DataItem, DataSink, DataSource } from './types';
3
3
  import type { GivenTypeInfo } from './loader/ts-utils';
4
- import { Narrative, Slice, Example, CommandSlice, QuerySlice, ExperienceSlice } from './index';
4
+ import { Narrative, Slice, CommandSlice, QuerySlice, ExperienceSlice } from './index';
5
5
  import type { ClientSpecNode } from './schema';
6
+ import type { z } from 'zod';
7
+ import type { StepSchema, ExampleSchema, SpecSchema, RuleSchema } from './schema';
6
8
 
7
- function normalizeContext(context?: Partial<Record<string, string>>): Record<string, string> | undefined {
8
- if (!context) return undefined;
9
-
10
- const filtered: Record<string, string> = {};
11
- for (const [key, value] of Object.entries(context)) {
12
- if (value !== undefined) {
13
- filtered[key] = value;
14
- }
15
- }
16
-
17
- return Object.keys(filtered).length > 0 ? filtered : undefined;
18
- }
9
+ type Step = z.infer<typeof StepSchema>;
10
+ type Example = z.infer<typeof ExampleSchema>;
11
+ type Spec = z.infer<typeof SpecSchema>;
12
+ type Rule = z.infer<typeof RuleSchema>;
19
13
 
20
14
  const debug = createDebug('auto:narrative:context:given-types');
21
15
 
16
+ type MajorKeyword = 'Given' | 'When' | 'Then';
17
+
22
18
  interface NarrativeContext {
23
19
  narrative: Narrative;
24
20
  currentSliceIndex: number | null;
@@ -27,13 +23,16 @@ interface NarrativeContext {
27
23
  currentRuleIndex: number | null;
28
24
  currentExampleIndex: number | null;
29
25
  clientSpecStack: ClientSpecNode[];
26
+ lastMajorKeyword: MajorKeyword | null;
30
27
  }
31
28
 
32
29
  let context: NarrativeContext | null = null;
33
30
  let givenTypesByFile: Map<string, GivenTypeInfo[]> = new Map();
34
31
  let whenTypesByFile: Map<string, GivenTypeInfo[]> = new Map();
32
+ let thenTypesByFile: Map<string, GivenTypeInfo[]> = new Map();
35
33
  const givenCallCounters: Map<string, number> = new Map();
36
34
  const whenCallCounters: Map<string, number> = new Map();
35
+ const thenCallCounters: Map<string, number> = new Map();
37
36
 
38
37
  export function setGivenTypesByFile(types: Map<string, GivenTypeInfo[]>): void {
39
38
  const whenTypes = types.get('__whenTypes') as Map<string, GivenTypeInfo[]> | undefined;
@@ -42,9 +41,16 @@ export function setGivenTypesByFile(types: Map<string, GivenTypeInfo[]>): void {
42
41
  types.delete('__whenTypes');
43
42
  }
44
43
 
44
+ const thenTypes = types.get('__thenTypes') as Map<string, GivenTypeInfo[]> | undefined;
45
+ if (thenTypes) {
46
+ thenTypesByFile = thenTypes;
47
+ types.delete('__thenTypes');
48
+ }
49
+
45
50
  givenTypesByFile = types;
46
51
  givenCallCounters.clear();
47
52
  whenCallCounters.clear();
53
+ thenCallCounters.clear();
48
54
  }
49
55
 
50
56
  export function startNarrative(name: string, id?: string): Narrative {
@@ -52,6 +58,7 @@ export function startNarrative(name: string, id?: string): Narrative {
52
58
  if (sourceFile !== null && sourceFile !== undefined && sourceFile !== '') {
53
59
  givenCallCounters.set(sourceFile, 0);
54
60
  whenCallCounters.set(sourceFile, 0);
61
+ thenCallCounters.set(sourceFile, 0);
55
62
  }
56
63
 
57
64
  const narrative: Narrative = {
@@ -68,6 +75,7 @@ export function startNarrative(name: string, id?: string): Narrative {
68
75
  currentRuleIndex: null,
69
76
  currentExampleIndex: null,
70
77
  clientSpecStack: [],
78
+ lastMajorKeyword: null,
71
79
  };
72
80
  return narrative;
73
81
  }
@@ -91,15 +99,20 @@ export function addSlice(slice: Slice): void {
91
99
  context.currentSliceIndex = context.narrative.slices.length - 1;
92
100
  }
93
101
 
94
- function getServerSpecs(
95
- slice: Slice,
96
- ): { name: string; rules: { id?: string; description: string; examples: Example[] }[] } | undefined {
102
+ function getServerSpecs(slice: Slice): Spec[] | undefined {
97
103
  if ('server' in slice) {
98
104
  return slice.server?.specs;
99
105
  }
100
106
  return undefined;
101
107
  }
102
108
 
109
+ function getCurrentSpec(slice: Slice): Spec | undefined {
110
+ if (!context || context.currentSpecIndex === null) return undefined;
111
+ const specs = getServerSpecs(slice);
112
+ if (!specs) return undefined;
113
+ return specs[context.currentSpecIndex];
114
+ }
115
+
103
116
  function getCurrentExample(slice: Slice): Example | undefined {
104
117
  if (
105
118
  !context ||
@@ -110,11 +123,10 @@ function getCurrentExample(slice: Slice): Example | undefined {
110
123
  return undefined;
111
124
  }
112
125
 
113
- const spec = getServerSpecs(slice);
126
+ const spec = getCurrentSpec(slice);
114
127
  if (!spec) return undefined;
115
128
 
116
- const objectRules = spec.rules as { id?: string; description: string; examples: Example[] }[];
117
- return objectRules[context.currentRuleIndex]?.examples[context.currentExampleIndex];
129
+ return spec.rules[context.currentRuleIndex]?.examples[context.currentExampleIndex];
118
130
  }
119
131
 
120
132
  export function startClientBlock(slice: Slice): void {
@@ -153,19 +165,19 @@ export function startServerBlock(slice: Slice, description: string = ''): void {
153
165
  if (slice.type === 'command') {
154
166
  slice.server = {
155
167
  description,
156
- specs: { name: '', rules: [] },
168
+ specs: [],
157
169
  data: undefined,
158
170
  };
159
171
  } else if (slice.type === 'query') {
160
172
  slice.server = {
161
173
  description,
162
- specs: { name: '', rules: [] },
174
+ specs: [],
163
175
  data: undefined,
164
176
  };
165
177
  } else if (slice.type === 'react') {
166
178
  slice.server = {
167
179
  description: description || undefined,
168
- specs: { name: '', rules: [] },
180
+ specs: [],
169
181
  data: undefined,
170
182
  };
171
183
  }
@@ -179,23 +191,25 @@ export function endServerBlock(): void {
179
191
  }
180
192
  }
181
193
 
182
- function initializeServerSpecs(slice: Slice, description: string): void {
194
+ function addServerSpec(slice: Slice, feature: string): void {
183
195
  if ('server' in slice && slice.server != null) {
184
- slice.server.specs = {
185
- name: description,
196
+ const newSpec: Spec = {
197
+ type: 'gherkin',
198
+ feature,
186
199
  rules: [],
187
200
  };
188
- if (context) context.currentSpecIndex = 0;
201
+ slice.server.specs.push(newSpec);
202
+ if (context) context.currentSpecIndex = slice.server.specs.length - 1;
189
203
  }
190
204
  }
191
205
 
192
- export function pushSpec(description: string): void {
206
+ export function pushSpec(feature: string): void {
193
207
  if (!context || !context.currentSpecTarget) throw new Error('No active spec target');
194
208
  const slice = getCurrentSlice();
195
209
  if (!slice) throw new Error('No active slice');
196
210
 
197
211
  if (context.currentSpecTarget === 'server') {
198
- initializeServerSpecs(slice, description);
212
+ addServerSpec(slice, feature);
199
213
  }
200
214
  }
201
215
 
@@ -293,193 +307,107 @@ function stripTypeDiscriminator(items: DataItem[]): (DataSink | DataSource)[] {
293
307
  });
294
308
  }
295
309
 
296
- export function recordRule(description: string, id?: string): void {
310
+ export function recordRule(name: string, id?: string): void {
297
311
  if (!context || context.currentSpecIndex === null) throw new Error('No active spec context');
298
312
  const slice = getCurrentSlice();
299
313
  if (!slice) throw new Error('No active slice');
300
314
 
301
- const spec = getServerSpecs(slice);
315
+ const spec = getCurrentSpec(slice);
302
316
  if (!spec) throw new Error('No active specs for current slice');
303
317
 
304
- const objectRules = spec.rules as { id?: string; description: string; examples: Example[] }[];
305
- objectRules.push({
318
+ const newRule: Rule = {
306
319
  id,
307
- description,
320
+ name,
308
321
  examples: [],
309
- });
310
- context.currentRuleIndex = objectRules.length - 1;
322
+ };
323
+ spec.rules.push(newRule);
324
+ context.currentRuleIndex = spec.rules.length - 1;
311
325
  }
312
326
 
313
- export function recordExample(description: string): void {
327
+ export function recordExample(name: string, id?: string): void {
314
328
  if (!context || context.currentSpecIndex === null || context.currentRuleIndex === null) {
315
329
  throw new Error('No active rule context');
316
330
  }
317
331
  const slice = getCurrentSlice();
318
332
  if (!slice) throw new Error('No active slice');
319
333
 
320
- const spec = getServerSpecs(slice);
334
+ const spec = getCurrentSpec(slice);
321
335
  if (!spec) throw new Error('No active specs for current slice');
322
336
 
323
- const objectRules = spec.rules as { id?: string; description: string; examples: Example[] }[];
324
- const rule = objectRules[context.currentRuleIndex];
325
- rule.examples.push({
326
- description,
327
- then: [],
328
- });
329
- context.currentExampleIndex = rule.examples.length - 1;
330
- }
331
-
332
- function processItemWithASTMatch(
333
- item: unknown,
334
- matchingType: import('./loader/ts-utils').GivenTypeInfo,
335
- contextParam?: Record<string, string>,
336
- ): { [key: string]: unknown; exampleData: unknown; context?: Record<string, string> } {
337
- const refType = getRefTypeFromClassification(matchingType.classification);
338
- return {
339
- [refType]: matchingType.typeName,
340
- exampleData: ensureMessageFormat(item).data,
341
- ...(contextParam && { context: contextParam }),
337
+ const rule = spec.rules[context.currentRuleIndex];
338
+ const newExample: Example = {
339
+ id,
340
+ name,
341
+ steps: [],
342
342
  };
343
+ rule.examples.push(newExample);
344
+ context.currentExampleIndex = rule.examples.length - 1;
345
+ context.lastMajorKeyword = null;
343
346
  }
344
347
 
345
- function processGivenItems(
346
- data: unknown[],
347
- contextParam?: Record<string, string>,
348
- ): Array<{ [key: string]: unknown; exampleData: unknown; context?: Record<string, string> }> {
349
- const sourceFile = context?.narrative.sourceFile;
348
+ type StepKeyword = 'Given' | 'When' | 'Then' | 'And';
349
+ type ErrorType = 'IllegalStateError' | 'ValidationError' | 'NotFoundError';
350
350
 
351
- return data.map((item) => {
352
- if (sourceFile !== null && sourceFile !== undefined && sourceFile !== '') {
353
- const currentCount = givenCallCounters.get(sourceFile) ?? 0;
354
- givenCallCounters.set(sourceFile, currentCount + 1);
355
-
356
- // Look up AST-extracted type info by ordinal position
357
- const givenTypes = givenTypesByFile.get(sourceFile) || [];
358
- const matchingType = givenTypes[currentCount];
359
-
360
- if (matchingType !== null && matchingType !== undefined) {
361
- debug('AST match for %s at ordinal %d: %s', sourceFile, currentCount, matchingType.typeName);
362
- return processItemWithASTMatch(item, matchingType, contextParam);
363
- } else {
364
- debug('No AST match for %s at ordinal %d, item: %o', sourceFile, currentCount, item);
365
- }
366
- }
367
- // Fallback: emit explicit InferredType for downstream processing
368
- return {
369
- eventRef: 'InferredType',
370
- exampleData: ensureMessageFormat(item).data,
371
- ...(contextParam && { context: contextParam }),
372
- };
373
- });
351
+ function isValidSourceFile(sourceFile: string | null | undefined): sourceFile is string {
352
+ return sourceFile !== null && sourceFile !== undefined && sourceFile !== '';
374
353
  }
375
354
 
376
- export function recordGivenData(data: unknown[], contextParam?: Partial<Record<string, string>>): void {
377
- if (
378
- !context ||
379
- context.currentSpecIndex === null ||
380
- context.currentRuleIndex === null ||
381
- context.currentExampleIndex === null
382
- ) {
383
- throw new Error('No active example context');
384
- }
385
- const slice = getCurrentSlice();
386
- if (!slice) throw new Error('No active slice');
387
-
388
- const example = getCurrentExample(slice);
389
- if (!example) throw new Error('No active example for current slice');
390
-
391
- const items = processGivenItems(data, normalizeContext(contextParam));
392
- example.given = items as typeof example.given;
355
+ function isValidMatchingType(
356
+ matchingType: import('./loader/ts-utils').GivenTypeInfo | undefined,
357
+ ): matchingType is import('./loader/ts-utils').GivenTypeInfo {
358
+ return matchingType !== null && matchingType !== undefined && matchingType.typeName !== '';
393
359
  }
394
360
 
395
- export function recordAndGivenData(data: unknown[], contextParam?: Partial<Record<string, string>>): void {
396
- if (
397
- !context ||
398
- context.currentSpecIndex === null ||
399
- context.currentRuleIndex === null ||
400
- context.currentExampleIndex === null
401
- ) {
402
- throw new Error('No active example context');
403
- }
404
- const slice = getCurrentSlice();
405
- if (!slice) throw new Error('No active slice');
406
-
407
- const example = getCurrentExample(slice);
408
- if (!example) throw new Error('No active example for current slice');
409
-
410
- const items = processGivenItems(data, normalizeContext(contextParam));
411
-
412
- if (example.given && Array.isArray(example.given)) {
413
- example.given.push(...(items as NonNullable<typeof example.given>));
414
- } else {
415
- example.given = items as NonNullable<typeof example.given>;
361
+ function resolveEffectiveKeyword(keyword: StepKeyword): MajorKeyword {
362
+ if (keyword !== 'And') {
363
+ return keyword;
416
364
  }
365
+ return context?.lastMajorKeyword ?? 'Given';
417
366
  }
418
367
 
419
- function updateExampleWhen(
420
- example: Example,
421
- data: unknown,
422
- sliceType: string,
423
- contextParam?: Record<string, string>,
424
- ): void {
425
- const ordinal = incrementWhenCounter();
426
-
427
- if (typeof data === 'object' && data !== null && Object.keys(data).length === 0) {
428
- if (sliceType === 'query') {
429
- example.when = { eventRef: '', exampleData: {} };
430
- } else {
431
- example.when = { commandRef: '', exampleData: {} };
432
- }
433
- return;
434
- }
368
+ function getTypesByFileForKeyword(keyword: MajorKeyword): Map<string, GivenTypeInfo[]> {
369
+ if (keyword === 'Given') return givenTypesByFile;
370
+ if (keyword === 'When') return whenTypesByFile;
371
+ return thenTypesByFile;
372
+ }
435
373
 
436
- if (sliceType === 'react' || (sliceType === 'query' && Array.isArray(data))) {
437
- // For react slices and query slices with array input, when is an array of events
438
- const eventsArray = Array.isArray(data) ? data : [data];
439
- example.when = eventsArray.map((item) => convertToEventExample(item, contextParam));
440
- } else {
441
- example.when = convertToCommandOrEventExample(data, contextParam, ordinal);
442
- }
374
+ function getCountersForKeyword(keyword: MajorKeyword): Map<string, number> {
375
+ if (keyword === 'Given') return givenCallCounters;
376
+ if (keyword === 'When') return whenCallCounters;
377
+ return thenCallCounters;
443
378
  }
444
379
 
445
- export function recordWhenData(data: unknown, contextParam?: Partial<Record<string, string>>): void {
446
- if (
447
- !context ||
448
- context.currentSpecIndex === null ||
449
- context.currentRuleIndex === null ||
450
- context.currentExampleIndex === null
451
- ) {
452
- throw new Error('No active example context');
380
+ function getTypeNameFromAST(effectiveKeyword: MajorKeyword): string | null {
381
+ const sourceFile = context?.narrative.sourceFile;
382
+ if (!isValidSourceFile(sourceFile)) {
383
+ return null;
453
384
  }
454
- const slice = getCurrentSlice();
455
- if (!slice) throw new Error('No active slice');
456
385
 
457
- const example = getCurrentExample(slice);
458
- if (!example) throw new Error('No active example for current slice');
386
+ const typesByFile = getTypesByFileForKeyword(effectiveKeyword);
387
+ const counters = getCountersForKeyword(effectiveKeyword);
459
388
 
460
- updateExampleWhen(example, data, slice.type, normalizeContext(contextParam));
461
- }
389
+ const currentCount = counters.get(sourceFile) ?? 0;
390
+ counters.set(sourceFile, currentCount + 1);
462
391
 
463
- export function recordThenData(data: unknown[], contextParam?: Record<string, string>): void {
464
- if (
465
- !context ||
466
- context.currentSpecIndex === null ||
467
- context.currentRuleIndex === null ||
468
- context.currentExampleIndex === null
469
- ) {
470
- throw new Error('No active example context');
471
- }
472
- const slice = getCurrentSlice();
473
- if (!slice) throw new Error('No active slice');
392
+ const types = typesByFile.get(sourceFile) || [];
393
+ const matchingType = types[currentCount];
474
394
 
475
- const example = getCurrentExample(slice);
476
- if (!example) throw new Error('No active example for current slice');
395
+ if (isValidMatchingType(matchingType)) {
396
+ debug(
397
+ 'AST match for %s keyword %s at ordinal %d: %s',
398
+ sourceFile,
399
+ effectiveKeyword,
400
+ currentCount,
401
+ matchingType.typeName,
402
+ );
403
+ return matchingType.typeName;
404
+ }
477
405
 
478
- const outcomes = data.map((item) => convertToOutcomeExample(item, slice.type, normalizeContext(contextParam)));
479
- example.then = outcomes as typeof example.then;
406
+ debug('No AST match for %s keyword %s at ordinal %d', sourceFile, effectiveKeyword, currentCount);
407
+ return null;
480
408
  }
481
409
 
482
- export function recordAndThenData(data: unknown[], contextParam?: Partial<Record<string, string>>): void {
410
+ function getActiveExampleContext(): { example: Example } {
483
411
  if (
484
412
  !context ||
485
413
  context.currentSpecIndex === null ||
@@ -490,220 +418,45 @@ export function recordAndThenData(data: unknown[], contextParam?: Partial<Record
490
418
  }
491
419
  const slice = getCurrentSlice();
492
420
  if (!slice) throw new Error('No active slice');
493
-
494
421
  const example = getCurrentExample(slice);
495
422
  if (!example) throw new Error('No active example for current slice');
496
-
497
- const outcomes = data.map((item) => convertToOutcomeExample(item, slice.type, normalizeContext(contextParam)));
498
- example.then.push(...(outcomes as typeof example.then));
423
+ return { example };
499
424
  }
500
425
 
501
- function convertToEventExample(
502
- item: unknown,
503
- contextParam?: Record<string, string>,
504
- ): { eventRef: string; exampleData: Record<string, unknown>; context?: Record<string, string> } {
505
- const message = ensureMessageFormat(item);
506
- return {
507
- eventRef: message.type,
508
- exampleData: message.data,
509
- ...(contextParam && { context: contextParam }),
510
- };
511
- }
512
-
513
- function getRefTypeFromClassification(classification: string): string {
514
- if (classification === 'event') return 'eventRef';
515
- if (classification === 'command') return 'commandRef';
516
- return 'stateRef';
517
- }
518
-
519
- function isValidSourceFile(sourceFile: string | null | undefined): sourceFile is string {
520
- return sourceFile !== null && sourceFile !== undefined && sourceFile !== '';
521
- }
522
-
523
- function isValidMatchingType(
524
- matchingType: import('./loader/ts-utils').GivenTypeInfo | undefined,
525
- ): matchingType is import('./loader/ts-utils').GivenTypeInfo {
526
- return matchingType !== null && matchingType !== undefined && matchingType.typeName !== '';
527
- }
528
-
529
- function incrementWhenCounter(): number {
530
- const sourceFile = context?.narrative.sourceFile;
426
+ export function recordStep(keyword: StepKeyword, text: string, data: unknown): void {
427
+ const { example } = getActiveExampleContext();
428
+ const effectiveKeyword = resolveEffectiveKeyword(keyword);
531
429
 
532
- if (!isValidSourceFile(sourceFile)) {
533
- return -1;
430
+ if (keyword !== 'And') {
431
+ context!.lastMajorKeyword = keyword as MajorKeyword;
534
432
  }
535
433
 
536
- const currentCount = whenCallCounters.get(sourceFile) ?? 0;
537
- whenCallCounters.set(sourceFile, currentCount + 1);
538
-
539
- debug('[when-counter] incremented counter for %s to %d', sourceFile, currentCount + 1);
434
+ const shouldInferType = text === 'InferredType';
435
+ const typeName = shouldInferType ? (getTypeNameFromAST(effectiveKeyword) ?? 'InferredType') : text;
540
436
 
541
- return currentCount;
542
- }
543
-
544
- function tryGetWhenTypeFromAST(
545
- item: unknown,
546
- ordinal: number,
547
- ): { commandRef: string; exampleData: Record<string, unknown> } | null {
548
- const sourceFile = context?.narrative.sourceFile;
437
+ const docString = typeof data === 'object' && data !== null ? (data as Record<string, unknown>) : undefined;
549
438
 
550
- debug('[when-ast] tryGetWhenTypeFromAST called, sourceFile=%s, ordinal=%d', sourceFile, ordinal);
551
- debug('[when-ast] whenTypesByFile has %d files', whenTypesByFile.size);
552
- debug('[when-ast] whenTypesByFile keys: %o', [...whenTypesByFile.keys()]);
553
-
554
- if (!isValidSourceFile(sourceFile)) {
555
- debug('[when-ast] sourceFile is null/undefined/empty, returning null');
556
- return null;
557
- }
558
-
559
- const whenTypes = whenTypesByFile.get(sourceFile) || [];
560
- debug('[when-ast] sourceFile=%s, ordinal=%d, whenTypes.length=%d', sourceFile, ordinal, whenTypes.length);
561
- if (whenTypes.length > 0) {
562
- debug(
563
- '[when-ast] available types: %o',
564
- whenTypes.map((t) => ({ typeName: t.typeName, classification: t.classification })),
565
- );
566
- }
567
-
568
- const matchingType = whenTypes[ordinal];
569
-
570
- if (!isValidMatchingType(matchingType)) {
571
- debug('[when-ast] No valid AST match for when at %s ordinal %d, falling back', sourceFile, ordinal);
572
- return null;
573
- }
574
-
575
- const refType = getRefTypeFromClassification(matchingType.classification);
576
- debug(
577
- '[when-ast] ✅ AST match for when at %s ordinal %d: %s -> %s',
578
- sourceFile,
579
- ordinal,
580
- matchingType.typeName,
581
- refType,
582
- );
583
-
584
- return {
585
- [refType]: matchingType.typeName,
586
- exampleData: ensureMessageFormat(item).data,
587
- } as { commandRef: string; exampleData: Record<string, unknown> };
588
- }
589
-
590
- function convertToCommandOrEventExample(
591
- item: unknown,
592
- contextParam?: Record<string, string>,
593
- ordinal?: number,
594
- ):
595
- | { commandRef: string; exampleData: Record<string, unknown> }
596
- | { eventRef: string; exampleData: Record<string, unknown> }[] {
597
- if (ordinal !== undefined && ordinal >= 0) {
598
- const astResult = tryGetWhenTypeFromAST(item, ordinal);
599
- if (astResult) return astResult;
600
- }
601
-
602
- // Fallback to the original logic
603
- const message = ensureMessageFormat(item);
604
- return {
605
- commandRef: message.type,
606
- exampleData: message.data,
607
- ...(contextParam && { context: contextParam }),
608
- };
609
- }
610
-
611
- function convertToOutcomeExample(
612
- item: unknown,
613
- sliceType: string,
614
- contextParam?: Record<string, string>,
615
- ):
616
- | { eventRef: string; exampleData: Record<string, unknown>; context?: Record<string, string> }
617
- | { stateRef: string; exampleData: Record<string, unknown>; context?: Record<string, string> }
618
- | { commandRef: string; exampleData: Record<string, unknown>; context?: Record<string, string> }
619
- | { errorType: 'IllegalStateError' | 'ValidationError' | 'NotFoundError'; message?: string } {
620
- const message = ensureMessageFormat(item);
621
-
622
- // Check if it's an error
623
- if (message.type === 'Error' || 'errorType' in message.data) {
624
- return {
625
- errorType:
626
- (message.data.errorType as 'IllegalStateError' | 'ValidationError' | 'NotFoundError') || 'IllegalStateError',
627
- message: message.data.message as string | undefined,
628
- };
629
- }
630
-
631
- if (sliceType === 'command') {
632
- return {
633
- eventRef: message.type,
634
- exampleData: message.data,
635
- };
636
- } else if (sliceType === 'query') {
637
- return {
638
- stateRef: message.type,
639
- exampleData: message.data,
640
- };
641
- } else if (sliceType === 'react') {
642
- return {
643
- commandRef: message.type,
644
- exampleData: message.data,
645
- };
646
- }
647
-
648
- return {
649
- eventRef: message.type,
650
- exampleData: message.data,
651
- ...(contextParam && { context: contextParam }),
439
+ const step: Step = {
440
+ keyword,
441
+ text: typeName,
442
+ ...(docString !== undefined && Object.keys(docString).length > 0 ? { docString } : {}),
652
443
  };
653
- }
654
-
655
- function ensureMessageFormat(item: unknown): { type: string; data: Record<string, unknown> } {
656
- if (typeof item !== 'object' || item === null) {
657
- throw new Error('Invalid message format');
658
- }
659
- const obj = item as Record<string, unknown>;
660
-
661
- if ('type' in obj && typeof obj.type === 'string') {
662
- return parseMessageObject(obj);
663
- }
664
444
 
665
- // Handle enhanced DSL format - pure data objects without type field
666
- // The type will be inferred during schema processing from the TypeScript types
667
- return {
668
- type: 'InferredType', // Placeholder - will be resolved during schema generation
669
- data: obj,
670
- };
445
+ example.steps.push(step);
671
446
  }
672
447
 
673
- function parseMessageObject(obj: Record<string, unknown>): { type: string; data: Record<string, unknown> } {
674
- // Handle direct type/data structure
675
- if (hasValidDataProperty(obj)) {
676
- return { type: obj.type as string, data: obj.data as Record<string, unknown> };
677
- }
678
-
679
- // Handle builder format with __messageCategory
680
- if ('__messageCategory' in obj) {
681
- return parseBuilderFormat(obj);
682
- }
683
-
684
- // Handle legacy format where properties are at top level
685
- return parseLegacyFormat(obj);
686
- }
448
+ export function recordErrorStep(errorType: ErrorType, message?: string): void {
449
+ const { example } = getActiveExampleContext();
687
450
 
688
- function hasValidDataProperty(obj: Record<string, unknown>): boolean {
689
- return 'data' in obj && typeof obj.data === 'object' && obj.data !== null;
690
- }
451
+ context!.lastMajorKeyword = 'Then';
691
452
 
692
- function parseBuilderFormat(obj: Record<string, unknown>): { type: string; data: Record<string, unknown> } {
693
- const data =
694
- 'data' in obj
695
- ? (obj.data as Record<string, unknown>)
696
- : Object.fromEntries(Object.entries(obj).filter(([key]) => key !== 'type' && key !== '__messageCategory'));
697
- return {
698
- type: obj.type as string,
699
- data,
453
+ const step: Step = {
454
+ keyword: 'Then',
455
+ error: {
456
+ type: errorType,
457
+ ...(message !== undefined ? { message } : {}),
458
+ },
700
459
  };
701
- }
702
460
 
703
- function parseLegacyFormat(obj: Record<string, unknown>): { type: string; data: Record<string, unknown> } {
704
- const data = Object.fromEntries(Object.entries(obj).filter(([key]) => key !== 'type'));
705
- return {
706
- type: obj.type as string,
707
- data,
708
- };
461
+ example.steps.push(step);
709
462
  }