@auto-engineer/model-diff 1.12.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.
- package/.turbo/turbo-build.log +5 -0
- package/.turbo/turbo-test.log +14 -0
- package/.turbo/turbo-type-check.log +4 -0
- package/CHANGELOG.md +17 -0
- package/LICENSE +10 -0
- package/dist/src/change-detector.d.ts +17 -0
- package/dist/src/change-detector.d.ts.map +1 -0
- package/dist/src/change-detector.js +37 -0
- package/dist/src/change-detector.js.map +1 -0
- package/dist/src/commands/detect-changes.d.ts +8 -0
- package/dist/src/commands/detect-changes.d.ts.map +1 -0
- package/dist/src/commands/detect-changes.js +72 -0
- package/dist/src/commands/detect-changes.js.map +1 -0
- package/dist/src/fingerprint.d.ts +19 -0
- package/dist/src/fingerprint.d.ts.map +1 -0
- package/dist/src/fingerprint.js +38 -0
- package/dist/src/fingerprint.js.map +1 -0
- package/dist/src/generation-state.d.ts +11 -0
- package/dist/src/generation-state.d.ts.map +1 -0
- package/dist/src/generation-state.js +33 -0
- package/dist/src/generation-state.js.map +1 -0
- package/dist/src/index.d.ts +12 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +4 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/model-dependencies.d.ts +26 -0
- package/dist/src/model-dependencies.d.ts.map +1 -0
- package/dist/src/model-dependencies.js +130 -0
- package/dist/src/model-dependencies.js.map +1 -0
- package/dist/src/stable-stringify.d.ts +2 -0
- package/dist/src/stable-stringify.d.ts.map +1 -0
- package/dist/src/stable-stringify.js +19 -0
- package/dist/src/stable-stringify.js.map +1 -0
- package/dist/src/utils.d.ts +2 -0
- package/dist/src/utils.d.ts.map +1 -0
- package/dist/src/utils.js +7 -0
- package/dist/src/utils.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/ketchup-plan.md +19 -0
- package/package.json +32 -0
- package/src/change-detector.specs.ts +138 -0
- package/src/change-detector.ts +65 -0
- package/src/commands/detect-changes.specs.ts +190 -0
- package/src/commands/detect-changes.ts +112 -0
- package/src/fingerprint.specs.ts +213 -0
- package/src/fingerprint.ts +59 -0
- package/src/generation-state.specs.ts +89 -0
- package/src/generation-state.ts +46 -0
- package/src/index.ts +7 -0
- package/src/model-dependencies.specs.ts +455 -0
- package/src/model-dependencies.ts +136 -0
- package/src/stable-stringify.specs.ts +45 -0
- package/src/stable-stringify.ts +19 -0
- package/src/utils.specs.ts +24 -0
- package/src/utils.ts +6 -0
- package/tsconfig.json +10 -0
- package/vitest.config.ts +21 -0
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
computeSharedTypesHash,
|
|
4
|
+
findCommandSource,
|
|
5
|
+
findEventSource,
|
|
6
|
+
getCommandSourceMap,
|
|
7
|
+
getEventSourceMap,
|
|
8
|
+
getReferencedIntegrations,
|
|
9
|
+
getReferencedMessageNames,
|
|
10
|
+
walkStepsByKeyword,
|
|
11
|
+
} from './model-dependencies';
|
|
12
|
+
|
|
13
|
+
const makeSpec = (steps: Array<{ keyword: 'Given' | 'When' | 'Then' | 'And'; text: string }>) => [
|
|
14
|
+
{
|
|
15
|
+
type: 'gherkin' as const,
|
|
16
|
+
feature: 'test',
|
|
17
|
+
rules: [{ name: 'r1', examples: [{ name: 'e1', steps }] }],
|
|
18
|
+
},
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
describe('walkStepsByKeyword', () => {
|
|
22
|
+
it('groups step texts by their keyword', () => {
|
|
23
|
+
const specs = makeSpec([
|
|
24
|
+
{ keyword: 'Given', text: 'EventA' },
|
|
25
|
+
{ keyword: 'When', text: 'CommandB' },
|
|
26
|
+
{ keyword: 'Then', text: 'EventC' },
|
|
27
|
+
]);
|
|
28
|
+
expect(walkStepsByKeyword(specs)).toEqual({
|
|
29
|
+
given: ['EventA'],
|
|
30
|
+
when: ['CommandB'],
|
|
31
|
+
then: ['EventC'],
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('resolves And to previous major keyword', () => {
|
|
36
|
+
const specs = makeSpec([
|
|
37
|
+
{ keyword: 'Given', text: 'E1' },
|
|
38
|
+
{ keyword: 'And', text: 'E2' },
|
|
39
|
+
{ keyword: 'When', text: 'C1' },
|
|
40
|
+
{ keyword: 'Then', text: 'Ev1' },
|
|
41
|
+
{ keyword: 'And', text: 'Ev2' },
|
|
42
|
+
]);
|
|
43
|
+
expect(walkStepsByKeyword(specs)).toEqual({
|
|
44
|
+
given: ['E1', 'E2'],
|
|
45
|
+
when: ['C1'],
|
|
46
|
+
then: ['Ev1', 'Ev2'],
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('skips error steps (no text property)', () => {
|
|
51
|
+
const specs = [
|
|
52
|
+
{
|
|
53
|
+
type: 'gherkin' as const,
|
|
54
|
+
feature: 'test',
|
|
55
|
+
rules: [
|
|
56
|
+
{
|
|
57
|
+
name: 'r1',
|
|
58
|
+
examples: [
|
|
59
|
+
{
|
|
60
|
+
name: 'e1',
|
|
61
|
+
steps: [
|
|
62
|
+
{ keyword: 'Given' as const, text: 'E1' },
|
|
63
|
+
{ keyword: 'When' as const, text: 'C1' },
|
|
64
|
+
{ keyword: 'Then' as const, error: { type: 'ValidationError' as const, message: 'bad' } },
|
|
65
|
+
],
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
},
|
|
69
|
+
],
|
|
70
|
+
},
|
|
71
|
+
];
|
|
72
|
+
expect(walkStepsByKeyword(specs)).toEqual({
|
|
73
|
+
given: ['E1'],
|
|
74
|
+
when: ['C1'],
|
|
75
|
+
then: [],
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('deduplicates step texts across examples', () => {
|
|
80
|
+
const specs = [
|
|
81
|
+
{
|
|
82
|
+
type: 'gherkin' as const,
|
|
83
|
+
feature: 'test',
|
|
84
|
+
rules: [
|
|
85
|
+
{
|
|
86
|
+
name: 'r1',
|
|
87
|
+
examples: [
|
|
88
|
+
{ name: 'e1', steps: [{ keyword: 'Given' as const, text: 'EventA' }] },
|
|
89
|
+
{ name: 'e2', steps: [{ keyword: 'Given' as const, text: 'EventA' }] },
|
|
90
|
+
],
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
},
|
|
94
|
+
];
|
|
95
|
+
expect(walkStepsByKeyword(specs)).toEqual({ given: ['EventA'], when: [], then: [] });
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('returns empty buckets for empty specs', () => {
|
|
99
|
+
expect(walkStepsByKeyword([])).toEqual({ given: [], when: [], then: [] });
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('getReferencedMessageNames', () => {
|
|
104
|
+
it('extracts message names from server spec steps', () => {
|
|
105
|
+
const slice = {
|
|
106
|
+
name: 'AddTodo',
|
|
107
|
+
type: 'command' as const,
|
|
108
|
+
client: { specs: [] },
|
|
109
|
+
server: {
|
|
110
|
+
description: '',
|
|
111
|
+
specs: makeSpec([
|
|
112
|
+
{ keyword: 'Given', text: 'TodoAdded' },
|
|
113
|
+
{ keyword: 'When', text: 'AddTodo' },
|
|
114
|
+
{ keyword: 'Then', text: 'TodoAdded' },
|
|
115
|
+
]),
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
expect(getReferencedMessageNames(slice)).toEqual(['TodoAdded', 'AddTodo']);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('includes data item target names', () => {
|
|
122
|
+
const slice = {
|
|
123
|
+
name: 'AddTodo',
|
|
124
|
+
type: 'command' as const,
|
|
125
|
+
client: { specs: [] },
|
|
126
|
+
server: {
|
|
127
|
+
description: '',
|
|
128
|
+
specs: makeSpec([
|
|
129
|
+
{ keyword: 'When', text: 'AddTodo' },
|
|
130
|
+
{ keyword: 'Then', text: 'TodoAdded' },
|
|
131
|
+
]),
|
|
132
|
+
data: {
|
|
133
|
+
items: [
|
|
134
|
+
{
|
|
135
|
+
target: { type: 'Event' as const, name: 'TodoAdded' },
|
|
136
|
+
destination: { type: 'stream' as const, pattern: 'todo-${id}' },
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
target: { type: 'State' as const, name: 'TodoList' },
|
|
140
|
+
origin: { type: 'projection' as const, name: 'todo-list' },
|
|
141
|
+
},
|
|
142
|
+
],
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
const names = getReferencedMessageNames(slice);
|
|
147
|
+
expect(names).toContain('AddTodo');
|
|
148
|
+
expect(names).toContain('TodoAdded');
|
|
149
|
+
expect(names).toContain('TodoList');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('returns empty array for experience slices', () => {
|
|
153
|
+
const slice = {
|
|
154
|
+
name: 'ViewTodos',
|
|
155
|
+
type: 'experience' as const,
|
|
156
|
+
client: { specs: [] },
|
|
157
|
+
};
|
|
158
|
+
expect(getReferencedMessageNames(slice)).toEqual([]);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('handles command slice with no data', () => {
|
|
162
|
+
const slice = {
|
|
163
|
+
name: 'DoThing',
|
|
164
|
+
type: 'command' as const,
|
|
165
|
+
client: { specs: [] },
|
|
166
|
+
server: {
|
|
167
|
+
description: '',
|
|
168
|
+
specs: makeSpec([
|
|
169
|
+
{ keyword: 'When', text: 'DoThing' },
|
|
170
|
+
{ keyword: 'Then', text: 'ThingDone' },
|
|
171
|
+
]),
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
expect(getReferencedMessageNames(slice)).toEqual(['DoThing', 'ThingDone']);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe('findEventSource', () => {
|
|
179
|
+
const flows = [
|
|
180
|
+
{
|
|
181
|
+
name: 'TodoFlow',
|
|
182
|
+
slices: [
|
|
183
|
+
{
|
|
184
|
+
name: 'AddTodo',
|
|
185
|
+
type: 'command' as const,
|
|
186
|
+
client: { specs: [] },
|
|
187
|
+
server: {
|
|
188
|
+
description: '',
|
|
189
|
+
specs: makeSpec([
|
|
190
|
+
{ keyword: 'When', text: 'AddTodo' },
|
|
191
|
+
{ keyword: 'Then', text: 'TodoAdded' },
|
|
192
|
+
]),
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
name: 'NotifyUser',
|
|
197
|
+
type: 'react' as const,
|
|
198
|
+
server: {
|
|
199
|
+
description: '',
|
|
200
|
+
specs: makeSpec([
|
|
201
|
+
{ keyword: 'Given', text: 'TodoAdded' },
|
|
202
|
+
{ keyword: 'When', text: 'TodoAdded' },
|
|
203
|
+
{ keyword: 'Then', text: 'SendNotification' },
|
|
204
|
+
]),
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
],
|
|
208
|
+
},
|
|
209
|
+
];
|
|
210
|
+
|
|
211
|
+
it('finds event source from command slice Then steps', () => {
|
|
212
|
+
expect(findEventSource(flows, 'TodoAdded')).toEqual({
|
|
213
|
+
flowName: 'TodoFlow',
|
|
214
|
+
sliceName: 'AddTodo',
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('does not find events from react slice Then steps', () => {
|
|
219
|
+
expect(findEventSource(flows, 'SendNotification')).toBeNull();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('returns null for unknown event', () => {
|
|
223
|
+
expect(findEventSource(flows, 'UnknownEvent')).toBeNull();
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe('findCommandSource', () => {
|
|
228
|
+
const flows = [
|
|
229
|
+
{
|
|
230
|
+
name: 'TodoFlow',
|
|
231
|
+
slices: [
|
|
232
|
+
{
|
|
233
|
+
name: 'AddTodo',
|
|
234
|
+
type: 'command' as const,
|
|
235
|
+
client: { specs: [] },
|
|
236
|
+
server: {
|
|
237
|
+
description: '',
|
|
238
|
+
specs: makeSpec([
|
|
239
|
+
{ keyword: 'When', text: 'AddTodo' },
|
|
240
|
+
{ keyword: 'Then', text: 'TodoAdded' },
|
|
241
|
+
]),
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
],
|
|
245
|
+
},
|
|
246
|
+
];
|
|
247
|
+
|
|
248
|
+
it('finds command source from command slice When steps', () => {
|
|
249
|
+
expect(findCommandSource(flows, 'AddTodo')).toEqual({
|
|
250
|
+
flowName: 'TodoFlow',
|
|
251
|
+
sliceName: 'AddTodo',
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('returns null for unknown command', () => {
|
|
256
|
+
expect(findCommandSource(flows, 'UnknownCommand')).toBeNull();
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
describe('getEventSourceMap', () => {
|
|
261
|
+
const flows = [
|
|
262
|
+
{
|
|
263
|
+
name: 'TodoFlow',
|
|
264
|
+
slices: [
|
|
265
|
+
{
|
|
266
|
+
name: 'AddTodo',
|
|
267
|
+
type: 'command' as const,
|
|
268
|
+
client: { specs: [] },
|
|
269
|
+
server: {
|
|
270
|
+
description: '',
|
|
271
|
+
specs: makeSpec([
|
|
272
|
+
{ keyword: 'When', text: 'AddTodo' },
|
|
273
|
+
{ keyword: 'Then', text: 'TodoAdded' },
|
|
274
|
+
]),
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
name: 'RemoveTodo',
|
|
279
|
+
type: 'command' as const,
|
|
280
|
+
client: { specs: [] },
|
|
281
|
+
server: {
|
|
282
|
+
description: '',
|
|
283
|
+
specs: makeSpec([
|
|
284
|
+
{ keyword: 'Given', text: 'TodoAdded' },
|
|
285
|
+
{ keyword: 'When', text: 'RemoveTodo' },
|
|
286
|
+
{ keyword: 'Then', text: 'TodoRemoved' },
|
|
287
|
+
]),
|
|
288
|
+
},
|
|
289
|
+
},
|
|
290
|
+
],
|
|
291
|
+
},
|
|
292
|
+
];
|
|
293
|
+
|
|
294
|
+
it('maps consumed Given events for command slices', () => {
|
|
295
|
+
const slice = flows[0].slices[1];
|
|
296
|
+
expect(getEventSourceMap(slice, flows)).toEqual({
|
|
297
|
+
TodoAdded: { flowName: 'TodoFlow', sliceName: 'AddTodo' },
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('maps consumed Given+When events for react slices', () => {
|
|
302
|
+
const reactSlice = {
|
|
303
|
+
name: 'Reactor',
|
|
304
|
+
type: 'react' as const,
|
|
305
|
+
server: {
|
|
306
|
+
description: '',
|
|
307
|
+
specs: makeSpec([
|
|
308
|
+
{ keyword: 'Given', text: 'TodoAdded' },
|
|
309
|
+
{ keyword: 'When', text: 'TodoRemoved' },
|
|
310
|
+
{ keyword: 'Then', text: 'SendNotification' },
|
|
311
|
+
]),
|
|
312
|
+
},
|
|
313
|
+
};
|
|
314
|
+
expect(getEventSourceMap(reactSlice, flows)).toEqual({
|
|
315
|
+
TodoAdded: { flowName: 'TodoFlow', sliceName: 'AddTodo' },
|
|
316
|
+
TodoRemoved: { flowName: 'TodoFlow', sliceName: 'RemoveTodo' },
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('returns empty map for experience slices', () => {
|
|
321
|
+
const expSlice = { name: 'View', type: 'experience' as const, client: { specs: [] } };
|
|
322
|
+
expect(getEventSourceMap(expSlice, flows)).toEqual({});
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
describe('getCommandSourceMap', () => {
|
|
327
|
+
const flows = [
|
|
328
|
+
{
|
|
329
|
+
name: 'TodoFlow',
|
|
330
|
+
slices: [
|
|
331
|
+
{
|
|
332
|
+
name: 'AddTodo',
|
|
333
|
+
type: 'command' as const,
|
|
334
|
+
client: { specs: [] },
|
|
335
|
+
server: {
|
|
336
|
+
description: '',
|
|
337
|
+
specs: makeSpec([
|
|
338
|
+
{ keyword: 'When', text: 'AddTodo' },
|
|
339
|
+
{ keyword: 'Then', text: 'TodoAdded' },
|
|
340
|
+
]),
|
|
341
|
+
},
|
|
342
|
+
},
|
|
343
|
+
],
|
|
344
|
+
},
|
|
345
|
+
];
|
|
346
|
+
|
|
347
|
+
it('maps produced commands for react slices', () => {
|
|
348
|
+
const reactSlice = {
|
|
349
|
+
name: 'AutoAdd',
|
|
350
|
+
type: 'react' as const,
|
|
351
|
+
server: {
|
|
352
|
+
description: '',
|
|
353
|
+
specs: makeSpec([
|
|
354
|
+
{ keyword: 'Given', text: 'TodoAdded' },
|
|
355
|
+
{ keyword: 'When', text: 'TodoAdded' },
|
|
356
|
+
{ keyword: 'Then', text: 'AddTodo' },
|
|
357
|
+
]),
|
|
358
|
+
},
|
|
359
|
+
};
|
|
360
|
+
expect(getCommandSourceMap(reactSlice, flows)).toEqual({
|
|
361
|
+
AddTodo: { flowName: 'TodoFlow', sliceName: 'AddTodo' },
|
|
362
|
+
});
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it('returns empty map for command slices', () => {
|
|
366
|
+
expect(getCommandSourceMap(flows[0].slices[0], flows)).toEqual({});
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it('returns empty map for experience slices', () => {
|
|
370
|
+
const expSlice = { name: 'View', type: 'experience' as const, client: { specs: [] } };
|
|
371
|
+
expect(getCommandSourceMap(expSlice, flows)).toEqual({});
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it('returns empty map for query slices', () => {
|
|
375
|
+
const querySlice = {
|
|
376
|
+
name: 'GetTodos',
|
|
377
|
+
type: 'query' as const,
|
|
378
|
+
client: { specs: [] },
|
|
379
|
+
server: {
|
|
380
|
+
description: '',
|
|
381
|
+
specs: makeSpec([
|
|
382
|
+
{ keyword: 'Given', text: 'TodoAdded' },
|
|
383
|
+
{ keyword: 'When', text: 'GetTodos' },
|
|
384
|
+
{ keyword: 'Then', text: 'TodoList' },
|
|
385
|
+
]),
|
|
386
|
+
},
|
|
387
|
+
};
|
|
388
|
+
expect(getCommandSourceMap(querySlice, flows)).toEqual({});
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
describe('getReferencedIntegrations', () => {
|
|
393
|
+
const integrations = [
|
|
394
|
+
{ name: 'MailChimp', description: 'Email service', source: '@auto-engineer/mailchimp' },
|
|
395
|
+
{ name: 'Twilio', description: 'SMS service', source: '@auto-engineer/twilio' },
|
|
396
|
+
];
|
|
397
|
+
|
|
398
|
+
it('filters integrations by slice.via names', () => {
|
|
399
|
+
const slice = { name: 'Notify', type: 'react' as const, via: ['MailChimp'], server: { specs: [] } };
|
|
400
|
+
expect(getReferencedIntegrations(slice, integrations)).toEqual([
|
|
401
|
+
{ name: 'MailChimp', description: 'Email service', source: '@auto-engineer/mailchimp' },
|
|
402
|
+
]);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it('returns undefined when slice has no via', () => {
|
|
406
|
+
const slice = {
|
|
407
|
+
name: 'AddTodo',
|
|
408
|
+
type: 'command' as const,
|
|
409
|
+
client: { specs: [] },
|
|
410
|
+
server: { description: '', specs: [] },
|
|
411
|
+
};
|
|
412
|
+
expect(getReferencedIntegrations(slice, integrations)).toBeUndefined();
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it('returns undefined when integrations is undefined', () => {
|
|
416
|
+
const slice = { name: 'Notify', type: 'react' as const, via: ['MailChimp'], server: { specs: [] } };
|
|
417
|
+
expect(getReferencedIntegrations(slice, undefined)).toBeUndefined();
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it('returns undefined when no via names match', () => {
|
|
421
|
+
const slice = { name: 'Notify', type: 'react' as const, via: ['Stripe'], server: { specs: [] } };
|
|
422
|
+
expect(getReferencedIntegrations(slice, integrations)).toBeUndefined();
|
|
423
|
+
});
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
describe('computeSharedTypesHash', () => {
|
|
427
|
+
it('hashes string literal union fields from messages', () => {
|
|
428
|
+
const messages = [
|
|
429
|
+
{
|
|
430
|
+
name: 'TodoAdded',
|
|
431
|
+
type: 'event' as const,
|
|
432
|
+
fields: [
|
|
433
|
+
{ name: 'status', type: "'active' | 'inactive'", required: true },
|
|
434
|
+
{ name: 'id', type: 'string', required: true },
|
|
435
|
+
],
|
|
436
|
+
},
|
|
437
|
+
];
|
|
438
|
+
expect(computeSharedTypesHash(messages)).toBe("TodoAdded.status:'active' | 'inactive'");
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it('returns empty string when no union fields exist', () => {
|
|
442
|
+
const messages = [
|
|
443
|
+
{ name: 'TodoAdded', type: 'event' as const, fields: [{ name: 'id', type: 'string', required: true }] },
|
|
444
|
+
];
|
|
445
|
+
expect(computeSharedTypesHash(messages)).toBe('');
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it('preserves model order for deterministic output', () => {
|
|
449
|
+
const messages = [
|
|
450
|
+
{ name: 'A', type: 'event' as const, fields: [{ name: 'x', type: "'a' | 'b'", required: true }] },
|
|
451
|
+
{ name: 'B', type: 'event' as const, fields: [{ name: 'y', type: "'c' | 'd'", required: true }] },
|
|
452
|
+
];
|
|
453
|
+
expect(computeSharedTypesHash(messages)).toBe("A.x:'a' | 'b'|B.y:'c' | 'd'");
|
|
454
|
+
});
|
|
455
|
+
});
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import type { Model, Narrative, Slice, Spec } from '@auto-engineer/narrative';
|
|
2
|
+
|
|
3
|
+
type StepBuckets = { given: string[]; when: string[]; then: string[] };
|
|
4
|
+
|
|
5
|
+
export function walkStepsByKeyword(specs: Spec[]): StepBuckets {
|
|
6
|
+
const given = new Set<string>();
|
|
7
|
+
const when = new Set<string>();
|
|
8
|
+
const then = new Set<string>();
|
|
9
|
+
|
|
10
|
+
for (const spec of specs) {
|
|
11
|
+
for (const rule of spec.rules) {
|
|
12
|
+
for (const example of rule.examples) {
|
|
13
|
+
let lastMajor: 'given' | 'when' | 'then' = 'given';
|
|
14
|
+
for (const step of example.steps) {
|
|
15
|
+
if (!('text' in step)) continue;
|
|
16
|
+
const keyword = step.keyword;
|
|
17
|
+
if (keyword === 'Given') lastMajor = 'given';
|
|
18
|
+
else if (keyword === 'When') lastMajor = 'when';
|
|
19
|
+
else if (keyword === 'Then') lastMajor = 'then';
|
|
20
|
+
const bucket = keyword === 'And' ? lastMajor : (keyword.toLowerCase() as 'given' | 'when' | 'then');
|
|
21
|
+
if (bucket === 'given') given.add(step.text);
|
|
22
|
+
else if (bucket === 'when') when.add(step.text);
|
|
23
|
+
else then.add(step.text);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return { given: [...given], when: [...when], then: [...then] };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function getServerSpecs(slice: Slice): Spec[] {
|
|
33
|
+
if ('server' in slice && slice.server) return slice.server.specs;
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function getReferencedMessageNames(slice: Slice): string[] {
|
|
38
|
+
const names = new Set<string>();
|
|
39
|
+
const specs = getServerSpecs(slice);
|
|
40
|
+
const buckets = walkStepsByKeyword(specs);
|
|
41
|
+
for (const text of [...buckets.given, ...buckets.when, ...buckets.then]) {
|
|
42
|
+
names.add(text);
|
|
43
|
+
}
|
|
44
|
+
if ('server' in slice && slice.server?.data?.items) {
|
|
45
|
+
for (const item of slice.server.data.items) {
|
|
46
|
+
names.add(item.target.name);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return [...names];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function findEventSource(flows: Narrative[], eventName: string): { flowName: string; sliceName: string } | null {
|
|
53
|
+
for (const flow of flows) {
|
|
54
|
+
for (const slice of flow.slices) {
|
|
55
|
+
if (slice.type !== 'command') continue;
|
|
56
|
+
const buckets = walkStepsByKeyword(slice.server.specs);
|
|
57
|
+
if (buckets.then.includes(eventName)) {
|
|
58
|
+
return { flowName: flow.name, sliceName: slice.name };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function findCommandSource(
|
|
66
|
+
flows: Narrative[],
|
|
67
|
+
commandName: string,
|
|
68
|
+
): { flowName: string; sliceName: string } | null {
|
|
69
|
+
for (const flow of flows) {
|
|
70
|
+
for (const slice of flow.slices) {
|
|
71
|
+
if (slice.type !== 'command') continue;
|
|
72
|
+
const buckets = walkStepsByKeyword(slice.server.specs);
|
|
73
|
+
if (buckets.when.includes(commandName)) {
|
|
74
|
+
return { flowName: flow.name, sliceName: slice.name };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
type SourceLocation = { flowName: string; sliceName: string };
|
|
82
|
+
|
|
83
|
+
export function getEventSourceMap(slice: Slice, flows: Narrative[]): Record<string, SourceLocation> {
|
|
84
|
+
const specs = getServerSpecs(slice);
|
|
85
|
+
if (specs.length === 0) return {};
|
|
86
|
+
|
|
87
|
+
const buckets = walkStepsByKeyword(specs);
|
|
88
|
+
const consumedEvents: string[] = [];
|
|
89
|
+
|
|
90
|
+
if (slice.type === 'command') {
|
|
91
|
+
consumedEvents.push(...buckets.given);
|
|
92
|
+
} else if (slice.type === 'react' || slice.type === 'query') {
|
|
93
|
+
consumedEvents.push(...buckets.given, ...buckets.when);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const result: Record<string, SourceLocation> = {};
|
|
97
|
+
for (const eventName of consumedEvents) {
|
|
98
|
+
const source = findEventSource(flows, eventName);
|
|
99
|
+
if (source) result[eventName] = source;
|
|
100
|
+
}
|
|
101
|
+
return result;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function getCommandSourceMap(slice: Slice, flows: Narrative[]): Record<string, SourceLocation> {
|
|
105
|
+
if (slice.type !== 'react') return {};
|
|
106
|
+
|
|
107
|
+
const specs = getServerSpecs(slice);
|
|
108
|
+
if (specs.length === 0) return {};
|
|
109
|
+
|
|
110
|
+
const buckets = walkStepsByKeyword(specs);
|
|
111
|
+
const result: Record<string, SourceLocation> = {};
|
|
112
|
+
for (const cmdName of buckets.then) {
|
|
113
|
+
const source = findCommandSource(flows, cmdName);
|
|
114
|
+
if (source) result[cmdName] = source;
|
|
115
|
+
}
|
|
116
|
+
return result;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function getReferencedIntegrations(slice: Slice, integrations?: Model['integrations']): Model['integrations'] {
|
|
120
|
+
if (!integrations || !slice.via || slice.via.length === 0) return undefined;
|
|
121
|
+
const viaSet = new Set(slice.via);
|
|
122
|
+
const filtered = integrations.filter((i) => viaSet.has(i.name));
|
|
123
|
+
return filtered.length > 0 ? filtered : undefined;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function computeSharedTypesHash(messages: Model['messages']): string {
|
|
127
|
+
const unionFields: string[] = [];
|
|
128
|
+
for (const msg of messages) {
|
|
129
|
+
for (const field of msg.fields) {
|
|
130
|
+
if (field.type.includes("'") && field.type.includes('|')) {
|
|
131
|
+
unionFields.push(`${msg.name}.${field.name}:${field.type}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return unionFields.join('|');
|
|
136
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { stableStringify } from './stable-stringify';
|
|
3
|
+
|
|
4
|
+
describe('stableStringify', () => {
|
|
5
|
+
it('produces identical output regardless of key order', () => {
|
|
6
|
+
const a = stableStringify({ z: 1, a: 2 });
|
|
7
|
+
const b = stableStringify({ a: 2, z: 1 });
|
|
8
|
+
expect(a).toBe(b);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('sorts nested object keys', () => {
|
|
12
|
+
const result = stableStringify({ b: { d: 1, c: 2 }, a: 3 });
|
|
13
|
+
expect(result).toBe('{"a":3,"b":{"c":2,"d":1}}');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('handles arrays without reordering elements', () => {
|
|
17
|
+
const result = stableStringify([3, 1, 2]);
|
|
18
|
+
expect(result).toBe('[3,1,2]');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('handles null and primitive values', () => {
|
|
22
|
+
expect(stableStringify(null)).toBe('null');
|
|
23
|
+
expect(stableStringify('hello')).toBe('"hello"');
|
|
24
|
+
expect(stableStringify(42)).toBe('42');
|
|
25
|
+
expect(stableStringify(true)).toBe('true');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('handles nested arrays with objects', () => {
|
|
29
|
+
const result = stableStringify([
|
|
30
|
+
{ z: 1, a: 2 },
|
|
31
|
+
{ y: 3, b: 4 },
|
|
32
|
+
]);
|
|
33
|
+
expect(result).toBe('[{"a":2,"z":1},{"b":4,"y":3}]');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('handles empty objects and arrays', () => {
|
|
37
|
+
expect(stableStringify({})).toBe('{}');
|
|
38
|
+
expect(stableStringify([])).toBe('[]');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('handles undefined values by omitting them', () => {
|
|
42
|
+
const result = stableStringify({ a: 1, b: undefined, c: 3 });
|
|
43
|
+
expect(result).toBe('{"a":1,"c":3}');
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export function stableStringify(value: unknown): string {
|
|
2
|
+
if (value === null || typeof value !== 'object') {
|
|
3
|
+
return JSON.stringify(value);
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
if (Array.isArray(value)) {
|
|
7
|
+
return `[${value.map((item) => stableStringify(item)).join(',')}]`;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const obj = value as Record<string, unknown>;
|
|
11
|
+
const sortedKeys = Object.keys(obj).sort();
|
|
12
|
+
const parts: string[] = [];
|
|
13
|
+
for (const key of sortedKeys) {
|
|
14
|
+
const val = obj[key];
|
|
15
|
+
if (val === undefined) continue;
|
|
16
|
+
parts.push(`${JSON.stringify(key)}:${stableStringify(val)}`);
|
|
17
|
+
}
|
|
18
|
+
return `{${parts.join(',')}}`;
|
|
19
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { toKebabCase } from './utils';
|
|
3
|
+
|
|
4
|
+
describe('toKebabCase', () => {
|
|
5
|
+
it('converts PascalCase to kebab-case', () => {
|
|
6
|
+
expect(toKebabCase('PropertyListing')).toBe('property-listing');
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('converts camelCase to kebab-case', () => {
|
|
10
|
+
expect(toKebabCase('createUser')).toBe('create-user');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('converts spaced strings to kebab-case', () => {
|
|
14
|
+
expect(toKebabCase('Property Listing')).toBe('property-listing');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('handles single word', () => {
|
|
18
|
+
expect(toKebabCase('Todo')).toBe('todo');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('handles already kebab-case', () => {
|
|
22
|
+
expect(toKebabCase('property-listing')).toBe('property-listing');
|
|
23
|
+
});
|
|
24
|
+
});
|
package/src/utils.ts
ADDED
package/tsconfig.json
ADDED