@delurio/cli 0.0.0-stage → 0.1.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,547 @@
1
+ import { alternatives, GraphError, humanize, titleOf } from './graph.js';
2
+ import { exitLine, kindOf, plain, pressLabel, responseLine } from './edges.js';
3
+ import { explore, finalPaths, Model } from './model.js';
4
+ import { mainStoryIndex, narrate, stepsByEvents, stepsTo } from './story.js';
5
+ import { deepDiff, fieldChanges, joinWords, listChanges, matchOrdered, moved, otherKeys, quote, same, union } from './compare.js';
6
+ const raw = (x) => (x ?? {});
7
+ const opOf = (b, a) => (b === undefined ? 'added' : a === undefined ? 'removed' : 'changed');
8
+ const BY_WORDS = { human: 'a person', ai: 'AI', agent: 'an agent', ci: 'CI' };
9
+ const stateName = (g, id) => (g.flow.states[id] === undefined ? id : quote(titleOf(g.flow, id)));
10
+ function stepKind(g, id) {
11
+ const st = g.flow.states[id];
12
+ if (st.type === 'final')
13
+ return `an ending: ${exitLine(st)}`;
14
+ if (st.goal === true)
15
+ return 'a goal';
16
+ if (st.by !== undefined)
17
+ return `done by ${BY_WORDS[st.by] ?? st.by}`;
18
+ const k = kindOf(g, id);
19
+ return k === 'screen' ? 'a screen' : k === 'subflow' ? 'another flow' : 'a server step';
20
+ }
21
+ function invokeWords(v) {
22
+ const inv = raw(v);
23
+ if (typeof inv.action === 'string')
24
+ return `the action ${inv.action}`;
25
+ if (typeof inv.flow === 'string')
26
+ return `the flow ${inv.flow}`;
27
+ if (typeof inv.external === 'string')
28
+ return inv.external;
29
+ return 'nothing';
30
+ }
31
+ // how people know a transition: the button that sends it, its `say`, or the event in words
32
+ function wayWords(g, state, event, t, index) {
33
+ if (event === 'always')
34
+ return `rule ${index + 1} (${quote(t.say ?? t.guard ?? 'otherwise')})`;
35
+ const press = pressLabel(g, state, event);
36
+ if (press !== null)
37
+ return `pressing ${quote(press)}`;
38
+ if (t.say !== undefined)
39
+ return quote(t.say);
40
+ return event.startsWith('FAIL.') ? `the failure ${event.slice('FAIL.'.length)}` : humanize(event).toLowerCase();
41
+ }
42
+ const wayKey = (state, event, index, count) => event === 'always' ? `state ${state}, rule ${index + 1}` : `state ${state}, on ${event}${count > 1 ? ` #${index + 1}` : ''}`;
43
+ function labelWords(g, label) {
44
+ if (label === undefined)
45
+ return null;
46
+ if (label.key !== undefined)
47
+ return plain(g, label.key);
48
+ if (label.literal !== undefined)
49
+ return label.literal;
50
+ if (label.oneOf !== undefined)
51
+ return label.oneOf.map((k) => plain(g, k)).join(' / ');
52
+ return null;
53
+ }
54
+ function nodeWords(g, id, n) {
55
+ const what = humanize(n.component ?? n.type).toLowerCase();
56
+ const text = labelWords(g, n.text) ?? labelWords(g, n.name);
57
+ return text === null ? `${what} ${id}` : `${what} ${quote(text)}`;
58
+ }
59
+ const shownOn = (n) => (n.in ?? []).filter((s) => !(n.unless ?? []).includes(s));
60
+ function valueWords(name, cv, v) {
61
+ const said = cv.say?.[String(v)];
62
+ return said !== undefined && said !== '' ? quote(said) : `${name} = ${String(v)}`;
63
+ }
64
+ export function flowStructure(b, a) {
65
+ const out = [];
66
+ const fb = b.g.flow;
67
+ const fa = a.g.flow;
68
+ const rb = raw(fb);
69
+ const ra = raw(fa);
70
+ // the flow itself
71
+ out.push(...fieldChanges('flow', 'flow', rb, ra, ['title', 'summary', 'status', 'initial', 'id', 'ui', 'domain', 'schema', 'kind'], (f, x, y) => {
72
+ if (f === 'title')
73
+ return `The flow is now called ${quote(fa.title ?? humanize(fa.id))} (was ${quote(fb.title ?? humanize(fb.id))}).`;
74
+ if (f === 'summary')
75
+ return y === undefined ? 'The summary was removed.' : `New summary: ${quote(String(y).trim())}`;
76
+ if (f === 'status')
77
+ return `Status: ${String(x ?? 'none')} → ${String(y ?? 'none')}.`;
78
+ if (f === 'initial')
79
+ return `It now starts at ${stateName(a.g, fa.initial)} (was ${stateName(b.g, fb.initial)}).`;
80
+ return null;
81
+ }));
82
+ for (const id of union(fb.decisions, fa.decisions)) {
83
+ const x = fb.decisions?.[id];
84
+ const y = fa.decisions?.[id];
85
+ if (same(x, y))
86
+ continue;
87
+ const key = `decision ${id}`;
88
+ if (x === undefined || y === undefined) {
89
+ const d = (y ?? x);
90
+ out.push({ area: 'flow', key, op: opOf(x, y), before: x, after: y, say: y === undefined ? `No longer open: ${quote(d.question)}.` : `New open decision: ${quote(d.question)}; recommended: ${d.recommend}.` });
91
+ continue;
92
+ }
93
+ out.push(...fieldChanges('flow', key, raw(x), raw(y), ['question', 'recommend', 'affects'], (f) => {
94
+ if (f === 'question')
95
+ return `An open decision is now worded ${quote(y.question)} (was ${quote(x.question)}).`;
96
+ if (f === 'recommend')
97
+ return `The recommendation for ${quote(y.question)} is now: ${y.recommend}.`;
98
+ return `The decision ${quote(y.question)} now affects ${joinWords((y.affects ?? []).map((s) => stateName(a.g, s)))}.`;
99
+ }));
100
+ otherKeys(key, raw(x), raw(y), ['question', 'recommend', 'affects'], out);
101
+ }
102
+ // what this view shows
103
+ const sb = fb.scope ?? {};
104
+ const sa = fa.scope ?? {};
105
+ if (!same(sb.shows, sa.shows)) {
106
+ out.push({ area: 'scope', key: 'scope', op: opOf(sb.shows, sa.shows), field: 'shows', before: sb.shows, after: sa.shows, say: sa.shows === undefined ? 'The view no longer says what it shows.' : `What this view shows: ${quote(sa.shows)}${sb.shows === undefined ? '' : ` (was ${quote(sb.shows)})`}.` });
107
+ }
108
+ if (!same(sb.world, sa.world)) {
109
+ out.push({ area: 'scope', key: 'scope', op: opOf(sb.world, sa.world), field: 'world', before: sb.world, after: sa.world, say: sa.world === 'open' ? 'The view is now an open world: what is not drawn is not ruled out.' : 'The view is no longer an open world.' });
110
+ }
111
+ const oov = listChanges(sb.outOfView, sa.outOfView);
112
+ for (const x of oov.added)
113
+ out.push({ area: 'scope', key: 'scope', op: 'added', field: 'outOfView', after: x, say: `Now left out of this view: ${quote(x)}.` });
114
+ for (const x of oov.removed)
115
+ out.push({ area: 'scope', key: 'scope', op: 'removed', field: 'outOfView', before: x, say: `No longer left out of this view: ${quote(x)}.` });
116
+ otherKeys('scope', raw(sb), raw(sa), ['shows', 'world', 'outOfView'], out);
117
+ // the declared main story
118
+ const norm = (m) => (m === undefined ? undefined : Array.isArray(m) ? { given: {}, events: m } : { given: m.given ?? {}, events: m.events });
119
+ if (!same(norm(fb.main), norm(fa.main))) {
120
+ out.push({
121
+ area: 'main',
122
+ key: 'main',
123
+ op: opOf(fb.main, fa.main),
124
+ before: norm(fb.main),
125
+ after: norm(fa.main),
126
+ say: fa.main === undefined ? 'The flow no longer declares its main story; the first success exit through a screen is used.' : fb.main === undefined ? 'The flow now declares its main story.' : 'The declared main story sends other events, or starts from another situation.',
127
+ });
128
+ }
129
+ // what it keeps track of
130
+ for (const v of union(fb.context, fa.context)) {
131
+ const x = fb.context[v];
132
+ const y = fa.context[v];
133
+ if (same(x, y))
134
+ continue;
135
+ const key = `context ${v}`;
136
+ if (x === undefined || y === undefined) {
137
+ const cv = (y ?? x);
138
+ const any = cv.initial === 'any';
139
+ const values = joinWords(cv.values.map((val) => valueWords(v, cv, val)));
140
+ const say = y !== undefined
141
+ ? any
142
+ ? `New in the starting situation: ${values}.`
143
+ : `The flow now keeps track of ${v} (it starts as ${String(cv.initial)}).`
144
+ : any
145
+ ? `No longer part of the starting situation: ${values}.`
146
+ : `The flow no longer keeps track of ${v}.`;
147
+ out.push({ area: 'situations', key, op: opOf(x, y), before: x, after: y, say });
148
+ continue;
149
+ }
150
+ if (!same(x.values, y.values)) {
151
+ const vals = listChanges(x.values.map(String), y.values.map(String));
152
+ const parts = [];
153
+ if (vals.added.length > 0)
154
+ parts.push(`can now also be ${joinWords(vals.added.map((val) => valueWords(v, y, val)))}`);
155
+ if (vals.removed.length > 0)
156
+ parts.push(`can no longer be ${joinWords(vals.removed.map((val) => valueWords(v, x, val)))}`);
157
+ if (parts.length === 0)
158
+ parts.push('lists its values in a new order (the order in which starting situations are tried)');
159
+ out.push({ area: 'situations', key, op: 'changed', field: 'values', before: x.values, after: y.values, say: `${v} ${parts.join('; ')}.` });
160
+ }
161
+ if (!same(x.initial, y.initial)) {
162
+ const w = (i) => (i === 'any' ? 'any of its values' : String(i));
163
+ out.push({ area: 'situations', key, op: 'changed', field: 'initial', before: x.initial, after: y.initial, say: `${v} now starts as ${w(y.initial)} (was ${w(x.initial)}).` });
164
+ }
165
+ for (const val of union(x.say, y.say)) {
166
+ const p = x.say?.[val];
167
+ const q = y.say?.[val];
168
+ if (p === q)
169
+ continue;
170
+ // an empty `say` leaves the value out of the starting sentence
171
+ const words = (t) => (t === '' ? 'left out of the starting sentence' : `described as ${quote(t)}`);
172
+ const say = q === undefined ? `${v} = ${val} is no longer described in words.` : p === undefined || p === '' || q === '' ? `${v} = ${val} is now ${words(q)}.` : `${quote(p)} now reads ${quote(q)}.`;
173
+ out.push({ area: 'situations', key, op: opOf(p, q), field: `say.${val}`, before: p, after: q, say });
174
+ }
175
+ otherKeys(key, raw(x), raw(y), ['values', 'initial', 'say'], out);
176
+ }
177
+ // named conditions and effects: formal, so only listed under the exact changes; their consequences show in behaviour
178
+ for (const n of union(fb.guards, fa.guards)) {
179
+ if (!same(fb.guards[n], fa.guards[n]))
180
+ out.push({ area: 'conditions', key: `guard ${n}`, op: opOf(fb.guards[n], fa.guards[n]), before: fb.guards[n], after: fa.guards[n], say: null });
181
+ }
182
+ for (const n of union(fb.effects, fa.effects)) {
183
+ if (!same(fb.effects[n], fa.effects[n]))
184
+ out.push({ area: 'conditions', key: `effect ${n}`, op: opOf(fb.effects[n], fa.effects[n]), before: fb.effects[n], after: fa.effects[n], say: null });
185
+ }
186
+ // steps
187
+ const STATE_KNOWN = ['title', 'description', 'type', 'invoke', 'on', 'always', 'redirect', 'code', 'carries', 'leavesPage', 'by', 'goal', 'includes', 'response', 'source'];
188
+ const ENDING = ['type', 'redirect', 'code', 'carries', 'leavesPage'];
189
+ for (const id of union(fb.states, fa.states)) {
190
+ const x = fb.states[id];
191
+ const y = fa.states[id];
192
+ const key = `state ${id}`;
193
+ if (x === undefined) {
194
+ out.push({ area: 'steps', key, op: 'added', say: `New step ${stateName(a.g, id)} (${stepKind(a.g, id)}).` });
195
+ continue;
196
+ }
197
+ if (y === undefined) {
198
+ out.push({ area: 'steps', key, op: 'removed', say: `Step ${stateName(b.g, id)} removed.` });
199
+ continue;
200
+ }
201
+ const name = stateName(a.g, id);
202
+ out.push(...fieldChanges('steps', key, raw(x), raw(y), ['title', 'by', 'goal', 'description', 'invoke', 'includes', 'response', 'source'], (f, p, q) => {
203
+ if (f === 'title')
204
+ return `${stateName(b.g, id)} is now called ${name}.`;
205
+ if (f === 'by')
206
+ return q === undefined ? `${name} no longer says who does it.` : `${name} is now done by ${BY_WORDS[String(q)] ?? String(q)}${p === undefined ? '' : ` (was ${BY_WORDS[String(p)] ?? String(p)})`}.`;
207
+ if (f === 'goal')
208
+ return q === true ? `${name} is now a goal.` : `${name} is no longer a goal.`;
209
+ if (f === 'description')
210
+ return q === undefined ? `${name} has no description any more.` : `${name} has a new description: ${quote(String(q))}.`;
211
+ if (f === 'invoke')
212
+ return `${name} now calls ${invokeWords(q)} (was ${invokeWords(p)}).`;
213
+ if (f === 'response')
214
+ return q === undefined ? `${name} no longer says what it answers.` : `${name} now answers ${quote(responseLine(q))}${p === undefined ? '' : ` (was ${quote(responseLine(p))})`}.`;
215
+ const inc = listChanges(p, q);
216
+ const parts = [inc.added.length > 0 ? `now also covers ${joinWords(inc.added.map(quote))}` : '', inc.removed.length > 0 ? `no longer covers ${joinWords(inc.removed.map(quote))}` : ''].filter((s) => s !== '');
217
+ return `${name} ${parts.length > 0 ? parts.join('; ') : 'lists what it covers in a new order'}.`;
218
+ }));
219
+ const eb = Object.fromEntries(ENDING.map((f) => [f, raw(x)[f]]));
220
+ const ea = Object.fromEntries(ENDING.map((f) => [f, raw(y)[f]]));
221
+ if (!same(eb, ea)) {
222
+ const say = y.type === 'final' && x.type === 'final' ? `${name} now ends with: ${exitLine(y)} (was ${exitLine(x)}).` : y.type === 'final' ? `${name} is now an ending: ${exitLine(y)}.` : x.type === 'final' ? `${name} is no longer an ending.` : `${name} changed its type.`;
223
+ out.push({ area: 'steps', key, op: 'changed', field: 'ending', before: eb, after: ea, say });
224
+ }
225
+ otherKeys(key, raw(x), raw(y), STATE_KNOWN, out);
226
+ }
227
+ // how it moves on: the alternatives of one event, and the rules of an `always`, are ordered (the first that matches wins).
228
+ // They are paired by their condition, then by their `say`, then by their target, so an inserted rule does not make the
229
+ // rules after it look rewritten; a pair that changed place relative to the other pairs is reported as moved.
230
+ const ways = [];
231
+ const added = [];
232
+ const TRANSITION_KNOWN = ['target', 'guard', 'effect', 'say', 'starts', 'source'];
233
+ for (const id of union(fb.states, fa.states)) {
234
+ const x = fb.states[id];
235
+ const y = fa.states[id];
236
+ const lists = [['always', x?.always ?? [], y?.always ?? []]];
237
+ for (const event of union(x?.on, y?.on))
238
+ lists.push([event, alternatives(x?.on?.[event] ?? []), alternatives(y?.on?.[event] ?? [])]);
239
+ for (const [event, bl, al] of lists) {
240
+ const pairs = matchOrdered(bl, al, [(t) => t.guard ?? '', (t) => t.say ?? null, (t) => t.target]);
241
+ const from = (g, i, t) => (event === 'always' ? `in ${stateName(g, id)}, ${wayWords(g, id, event, t, i)}` : `from ${stateName(g, id)}, ${wayWords(g, id, event, t, i)}`);
242
+ const pb = new Set(pairs.map((p) => p[0]));
243
+ const pa = new Set(pairs.map((p) => p[1]));
244
+ al.forEach((t, i) => {
245
+ if (pa.has(i))
246
+ return;
247
+ added.push(`${id}|${event}|${i}`);
248
+ out.push({ area: 'ways', key: wayKey(id, event, i, al.length), op: 'added', after: t, say: `New: ${from(a.g, i, t)} leads to ${stateName(a.g, t.target)}.` });
249
+ });
250
+ bl.forEach((t, i) => {
251
+ if (pb.has(i))
252
+ return;
253
+ out.push({ area: 'ways', key: wayKey(id, event, i, bl.length), op: 'removed', before: t, say: y === undefined ? null : `Removed: ${from(b.g, i, t)} led to ${stateName(b.g, t.target)}.` });
254
+ });
255
+ for (const [i, j] of pairs) {
256
+ ways.push([`${id}|${event}|${i}`, `${id}|${event}|${j}`]);
257
+ const p = bl[i];
258
+ const q = al[j];
259
+ const lead = from(a.g, j, q);
260
+ const cap = lead.charAt(0).toUpperCase() + lead.slice(1);
261
+ out.push(...fieldChanges('ways', wayKey(id, event, j, al.length), raw(p), raw(q), TRANSITION_KNOWN, (f) => {
262
+ if (f === 'target')
263
+ return `${cap} now leads to ${stateName(a.g, q.target)} (was ${stateName(b.g, p.target)}).`;
264
+ if (f === 'guard')
265
+ return `${cap}: the condition changed.`;
266
+ if (f === 'effect')
267
+ return `${cap}: what it records changed.`;
268
+ if (f === 'starts')
269
+ return q.starts === undefined ? `${cap} no longer starts ${p.starts?.flow}.` : p.starts === undefined ? `${cap} now starts ${q.starts.flow}.` : `${cap}: the flow it starts, or what that flow begins with, changed.`;
270
+ const where = event === 'always' ? `In ${stateName(a.g, id)}, rule ${j + 1}` : `From ${stateName(a.g, id)}, ${wayWords(a.g, id, event, { target: q.target }, j)}`;
271
+ return q.say === undefined ? `${where} is no longer described in words (was ${quote(p.say ?? '')}).` : p.say === undefined ? `${where} is now described as ${quote(q.say)}.` : `${where}: ${quote(p.say)} now reads ${quote(q.say)}.`;
272
+ }));
273
+ otherKeys(wayKey(id, event, j, al.length), raw(p), raw(q), TRANSITION_KNOWN, out);
274
+ }
275
+ for (const [i, j] of moved(pairs)) {
276
+ const t = al[j];
277
+ const say = event === 'always' ? `In ${stateName(a.g, id)}, ${quote(t.say ?? t.guard ?? 'otherwise')} is now rule ${j + 1} (was ${i + 1}); the first rule that matches wins.` : `From ${stateName(a.g, id)}, the alternatives of ${wayWords(a.g, id, event, t, j)} are checked in a new order.`;
278
+ out.push({ area: 'ways', key: wayKey(id, event, j, al.length), op: 'moved', before: i + 1, after: j + 1, say });
279
+ }
280
+ }
281
+ }
282
+ // guarantees
283
+ const byId = (list, side) => {
284
+ const m = {};
285
+ for (const x of list ?? []) {
286
+ if (m[x.id] !== undefined)
287
+ throw new GraphError(`${side}: the assertion ${x.id} is written twice`);
288
+ m[x.id] = x;
289
+ }
290
+ return m;
291
+ };
292
+ const asb = byId(fb.assertions, 'before');
293
+ const asa = byId(fa.assertions, 'after');
294
+ for (const id of union(asb, asa)) {
295
+ const x = asb[id];
296
+ const y = asa[id];
297
+ if (same(x, y))
298
+ continue;
299
+ const key = `assertion ${id}`;
300
+ if (x === undefined)
301
+ out.push({ area: 'guarantees', key, op: 'added', after: y.never, say: `New guarantee: ${quote(y.say ?? humanize(id))}.` });
302
+ else if (y === undefined)
303
+ out.push({ area: 'guarantees', key, op: 'removed', before: x.never, say: `Guarantee dropped: ${quote(x.say ?? humanize(id))}.` });
304
+ else {
305
+ out.push(...fieldChanges('guarantees', key, raw(x), raw(y), ['never', 'say'], (f) => f === 'never' ? `The guarantee ${quote(y.say ?? humanize(id))} now checks a different condition.` : `Guarantee reworded: ${quote(y.say ?? humanize(id))} (was ${quote(x.say ?? humanize(id))}).`));
306
+ otherKeys(key, raw(x), raw(y), ['id', 'never', 'say'], out);
307
+ }
308
+ }
309
+ otherKeys('flow', rb, ra, ['schema', 'kind', 'id', 'status', 'ui', 'title', 'summary', 'scope', 'context', 'guards', 'effects', 'main', 'initial', 'states', 'assertions', 'decisions', 'domain'], out);
310
+ // what the user sees
311
+ const ub = b.g.ui;
312
+ const ua = a.g.ui;
313
+ for (const k of union(ub.samples, ua.samples)) {
314
+ const p = ub.samples?.[k];
315
+ const q = ua.samples?.[k];
316
+ if (p === q)
317
+ continue;
318
+ out.push({ area: 'screens', key: 'ui samples', op: opOf(p, q), field: k, before: p, after: q, say: q === undefined ? `The sample ${k} is gone.` : `The sample ${k} is now ${quote(q)}${p === undefined ? '' : ` (was ${quote(p)})`}.` });
319
+ }
320
+ const NODE_KNOWN = ['type', 'component', 'role', 'intent', 'name', 'text', 'in', 'unless', 'when', 'disabledIn', 'emits', 'source'];
321
+ for (const id of union(ub.nodes, ua.nodes)) {
322
+ const x = ub.nodes[id];
323
+ const y = ua.nodes[id];
324
+ if (same(x, y))
325
+ continue;
326
+ const key = `ui node ${id}`;
327
+ const on = (g, n) => (shownOn(n).length === 0 ? 'no screen' : joinWords(shownOn(n).map((s) => stateName(g, s))));
328
+ if (x === undefined) {
329
+ out.push({ area: 'screens', key, op: 'added', say: `New on ${on(a.g, y)}: ${nodeWords(a.g, id, y)}.` });
330
+ continue;
331
+ }
332
+ if (y === undefined) {
333
+ out.push({ area: 'screens', key, op: 'removed', say: `Removed from ${on(b.g, x)}: ${nodeWords(b.g, id, x)}.` });
334
+ continue;
335
+ }
336
+ const what = nodeWords(a.g, id, y);
337
+ const cap = what.charAt(0).toUpperCase() + what.slice(1);
338
+ out.push(...fieldChanges('screens', key, raw(x), raw(y), NODE_KNOWN, (f, p, q) => {
339
+ if (f === 'in' || f === 'unless' || f === 'disabledIn') {
340
+ const before = f === 'disabledIn' ? (x.disabledIn ?? []) : shownOn(x);
341
+ const after = f === 'disabledIn' ? (y.disabledIn ?? []) : shownOn(y);
342
+ const ch = listChanges(before, after);
343
+ const verb = f === 'disabledIn' ? 'disabled' : 'shown';
344
+ const parts = [ch.added.length > 0 ? `is now ${verb} on ${joinWords(ch.added.map((s) => stateName(a.g, s)))}` : '', ch.removed.length > 0 ? `is no longer ${verb} on ${joinWords(ch.removed.map((s) => stateName(b.g, s)))}` : ''].filter((s) => s !== '');
345
+ return parts.length === 0 ? null : `${cap} ${parts.join('; ')}.`;
346
+ }
347
+ if (f === 'text')
348
+ return `${quote(labelWords(b.g, x.text) ?? '')} now reads ${quote(labelWords(a.g, y.text) ?? '')}.`;
349
+ if (f === 'name')
350
+ return `The accessible name of the ${what} is now ${quote(labelWords(a.g, y.name) ?? '(from another node)')} (was ${quote(labelWords(b.g, x.name) ?? '(from another node)')}).`;
351
+ if (f === 'when')
352
+ return q === undefined ? `${cap} no longer depends on a flag.` : `${cap} is now shown only when ${String(q)}.`;
353
+ if (f === 'emits')
354
+ return q === undefined ? `${cap} no longer sends an event.` : `${cap} now sends ${String(q)}${p === undefined ? '' : ` (was ${String(p)})`}.`;
355
+ if (f === 'component' || f === 'type')
356
+ return `${cap} is now drawn as ${humanize(String(q ?? 'nothing')).toLowerCase()} (was ${humanize(String(p ?? 'nothing')).toLowerCase()}).`;
357
+ return `${cap} now has the ${f} ${String(q ?? 'none')} (was ${String(p ?? 'none')}).`;
358
+ }));
359
+ otherKeys(key, raw(x), raw(y), NODE_KNOWN, out);
360
+ }
361
+ otherKeys('ui', raw(ub), raw(ua), ['schema', 'kind', 'id', 'samples', 'nodes'], out);
362
+ // wording
363
+ for (const k of union(b.g.catalog, a.g.catalog)) {
364
+ const p = b.g.catalog[k];
365
+ const q = a.g.catalog[k];
366
+ if (p === q)
367
+ continue;
368
+ const say = p === undefined ? `New text: ${quote(plain(a.g, k))}.` : q === undefined ? `Text removed: ${quote(plain(b.g, k))}.` : `${quote(plain(b.g, k))} now reads ${quote(plain(a.g, k))}.`;
369
+ out.push({ area: 'wording', key: `catalog ${k}`, op: opOf(p, q), before: p, after: q, say });
370
+ }
371
+ // the API it calls
372
+ if (b.domain === null && a.domain !== null)
373
+ out.push({ area: 'domain', key: 'domain', op: 'added', after: a.domain.file, say: `The graph now has an API description (${a.domain.file}).` });
374
+ else if (b.domain !== null && a.domain === null)
375
+ out.push({ area: 'domain', key: 'domain', op: 'removed', before: b.domain.file, say: `The API description (${b.domain.file}) is no longer next to the graph.` });
376
+ else if (b.domain !== null && a.domain !== null)
377
+ deepDiff('domain', 'domain', b.domain.doc, a.domain.doc, out);
378
+ return { changes: out, ways, added };
379
+ }
380
+ function run(g) {
381
+ const m = new Model(g.flow);
382
+ const ex = explore(m);
383
+ const violations = new Map();
384
+ for (const a of g.flow.assertions ?? []) {
385
+ const hit = ex.configs.findIndex((c) => m.test(a.never, c.state, c.ctx));
386
+ if (hit >= 0)
387
+ violations.set(a.id, hit);
388
+ }
389
+ const exits = new Map(finalPaths(m, ex).map((f) => [f.state, f.index]));
390
+ let moves = 0;
391
+ for (const c of ex.configs)
392
+ moves += m.steps(c.state, c.ctx).length;
393
+ const declared = g.flow.main === undefined ? null : stepsByEvents(m, g.flow.main);
394
+ const index = mainStoryIndex(g, ex);
395
+ const main = declared ?? (index === null ? null : stepsTo(m, ex, index));
396
+ return { g, m, ex, violations, exits, moves, main, mainBroken: g.flow.main !== undefined && declared === null, reached: new Set(ex.configs.map((c) => c.state)) };
397
+ }
398
+ // Runs one side; an `always` that is not exhaustive, an unknown name in a guard and the like stop the diff with the side named.
399
+ export function runSide(g, side) {
400
+ try {
401
+ return run(g);
402
+ }
403
+ catch (err) {
404
+ throw new GraphError(`${side}: ${err.message}`);
405
+ }
406
+ }
407
+ const capital = (s) => s.charAt(0).toUpperCase() + s.slice(1);
408
+ // a path as people read it: the starting situation, then one numbered line per step, the ending last
409
+ function told(r, steps) {
410
+ const story = narrate(r.g, r.m, steps);
411
+ const lines = r.g.flow.states[steps[steps.length - 1].state].type === 'final' ? [...story.steps, story.result] : story.steps;
412
+ return [`When: ${story.given}.`, ...lines.map((s, i) => `${i + 1}. ${s}`)];
413
+ }
414
+ const chain = (r, steps) => steps.map((s) => titleOf(r.g.flow, s.state)).join(' → ');
415
+ // the same path in both revisions: the same steps taken by the same events, from the same values of what both start with
416
+ function samePath(rb, ra, b, a) {
417
+ const walk = (steps) => steps.map((s) => `${s.event ?? ''}>${s.state}`).join(' ');
418
+ if (walk(b) !== walk(a))
419
+ return false;
420
+ const common = rb.m.vars.filter((v) => rb.g.flow.context[v].initial === 'any' && ra.g.flow.context[v]?.initial === 'any');
421
+ return common.every((v) => b[0].ctx[rb.m.vars.indexOf(v)] === a[0].ctx[ra.m.vars.indexOf(v)]);
422
+ }
423
+ function transitionAt(g, taken) {
424
+ const [state, event, idx] = taken.split('|');
425
+ const st = g.flow.states[state];
426
+ const index = Number(idx);
427
+ const t = event === 'always' ? (st.always ?? [])[index] : alternatives(st.on?.[event] ?? [])[index];
428
+ return { state, event, index, t };
429
+ }
430
+ export function flowBehaviour(rb, ra, s) {
431
+ const out = [];
432
+ const fb = rb.g.flow;
433
+ const fa = ra.g.flow;
434
+ const shift = (topic, key, before, after, say, detail = [], worse = false) => {
435
+ out.push({ topic, key, before, after, say, detail, worse });
436
+ };
437
+ // guarantees first: a broken one is what a reviewer must see
438
+ const said = (id) => quote(fa.assertions?.find((x) => x.id === id)?.say ?? fb.assertions?.find((x) => x.id === id)?.say ?? humanize(id));
439
+ for (const id of union(Object.fromEntries((fb.assertions ?? []).map((x) => [x.id, x])), Object.fromEntries((fa.assertions ?? []).map((x) => [x.id, x])))) {
440
+ const inB = (fb.assertions ?? []).some((x) => x.id === id);
441
+ const inA = (fa.assertions ?? []).some((x) => x.id === id);
442
+ const vb = rb.violations.get(id);
443
+ const va = ra.violations.get(id);
444
+ const at = (r, i) => {
445
+ const steps = stepsTo(r.m, r.ex, i);
446
+ return { state: r.ex.configs[i].state, start: r.m.scenario(steps[0].ctx), events: steps.flatMap((x) => (x.event === null ? [] : [x.event])) };
447
+ };
448
+ if (inA && va !== undefined && vb === undefined) {
449
+ const lead = inB ? 'A guarantee no longer holds' : 'A new guarantee does not hold';
450
+ shift('guarantee', id, inB ? 'holds' : 'absent', { violated: at(ra, va) }, `${lead}: ${said(id)}. It breaks in ${stateName(ra.g, ra.ex.configs[va].state)}:`, told(ra, stepsTo(ra.m, ra.ex, va)), true);
451
+ }
452
+ else if (inA && inB && vb !== undefined && va === undefined) {
453
+ shift('guarantee', id, { violated: at(rb, vb) }, 'holds', `A guarantee now holds: ${said(id)} (it was broken).`);
454
+ }
455
+ else if (inA && inB && vb !== undefined && va !== undefined && !samePath(rb, ra, stepsTo(rb.m, rb.ex, vb), stepsTo(ra.m, ra.ex, va))) {
456
+ shift('guarantee', id, { violated: at(rb, vb) }, { violated: at(ra, va) }, `Still broken, now first in ${stateName(ra.g, ra.ex.configs[va].state)}: ${said(id)}.`, told(ra, stepsTo(ra.m, ra.ex, va)));
457
+ }
458
+ else if (!inA && inB && vb !== undefined) {
459
+ shift('guarantee', id, { violated: at(rb, vb) }, 'absent', `No longer checked, and it was broken: ${said(id)}.`);
460
+ }
461
+ }
462
+ // the main story
463
+ if (rb.mainBroken !== ra.mainBroken) {
464
+ if (ra.mainBroken)
465
+ shift('main', 'main', 'walks', 'broken', 'The declared main story can no longer be walked: an event is not possible where it is sent.', [], true);
466
+ else
467
+ shift('main', 'main', 'broken', 'walks', 'The declared main story can be walked again.');
468
+ }
469
+ if (rb.main !== null && ra.main === null)
470
+ shift('main', 'main', chain(rb, rb.main), null, `There is no main story any more (it was: ${chain(rb, rb.main)}).`);
471
+ else if (rb.main === null && ra.main !== null)
472
+ shift('main', 'main', null, chain(ra, ra.main), 'There is now a main story:', told(ra, ra.main));
473
+ else if (rb.main !== null && ra.main !== null && !samePath(rb, ra, rb.main, ra.main)) {
474
+ shift('main', 'main', chain(rb, rb.main), chain(ra, ra.main), `The main story changed. It was: ${chain(rb, rb.main)}. Now:`, told(ra, ra.main));
475
+ }
476
+ // endings
477
+ const finals = union(fb.states, fa.states).filter((id) => fa.states[id]?.type === 'final' || fb.states[id]?.type === 'final');
478
+ for (const id of finals) {
479
+ const ib = rb.exits.get(id);
480
+ const ia = ra.exits.get(id);
481
+ if (ib === undefined && ia !== undefined) {
482
+ shift('exit', id, 'unreachable', 'reachable', `New way to end: ${stateName(ra.g, id)} (${exitLine(fa.states[id])}).`, told(ra, stepsTo(ra.m, ra.ex, ia)));
483
+ }
484
+ else if (ib !== undefined && ia === undefined) {
485
+ shift('exit', id, 'reachable', 'unreachable', `No longer reachable: ${stateName(rb.g, id)}${fa.states[id] === undefined ? ' (the ending was removed)' : ''}.`);
486
+ }
487
+ else if (ib !== undefined && ia !== undefined) {
488
+ const pb = stepsTo(rb.m, rb.ex, ib);
489
+ const pa = stepsTo(ra.m, ra.ex, ia);
490
+ if (samePath(rb, ra, pb, pa))
491
+ continue;
492
+ const nb = pb.length - 1;
493
+ const na = pa.length - 1;
494
+ const how = na === nb ? 'is now reached another way' : `is now reached in ${na} steps (was ${nb})`;
495
+ shift('path', id, chain(rb, pb), chain(ra, pa), `${capital(stateName(ra.g, id))} ${how}. It was: ${chain(rb, pb)}. Now:`, told(ra, pa));
496
+ }
497
+ }
498
+ // steps no situation reaches, and transitions no situation takes
499
+ for (const id of Object.keys(fa.states)) {
500
+ if (fa.states[id].type === 'final')
501
+ continue;
502
+ const before = fb.states[id] === undefined ? null : rb.reached.has(id);
503
+ const after = ra.reached.has(id);
504
+ if (before === true && !after)
505
+ shift('reach', id, true, false, `${capital(stateName(ra.g, id))} can no longer be reached.`);
506
+ else if (before === false && after)
507
+ shift('reach', id, false, true, `${capital(stateName(ra.g, id))} can now be reached.`);
508
+ else if (before === null && !after)
509
+ shift('reach', id, null, false, `The new step ${stateName(ra.g, id)} is never reached.`);
510
+ }
511
+ const wayName = (g, taken) => {
512
+ const w = transitionAt(g, taken);
513
+ return w.event === 'always' ? `in ${stateName(g, w.state)}, ${wayWords(g, w.state, w.event, w.t, w.index)}` : `from ${stateName(g, w.state)}, ${wayWords(g, w.state, w.event, w.t, w.index)}`;
514
+ };
515
+ for (const [kb, ka] of s.ways) {
516
+ const st = ka.split('|')[0];
517
+ if (!rb.reached.has(st) || !ra.reached.has(st))
518
+ continue;
519
+ const tb = rb.ex.taken.has(kb);
520
+ const ta = ra.ex.taken.has(ka);
521
+ if (tb && !ta)
522
+ shift('taken', ka, true, false, `${capital(wayName(ra.g, ka))} is never taken any more.`);
523
+ else if (!tb && ta)
524
+ shift('taken', ka, false, true, `${capital(wayName(ra.g, ka))} is now taken (it never was).`);
525
+ }
526
+ for (const ka of s.added) {
527
+ if (ra.reached.has(ka.split('|')[0]) && !ra.ex.taken.has(ka))
528
+ shift('taken', ka, null, false, `The new way ${wayName(ra.g, ka)} is never taken.`);
529
+ }
530
+ const nb = rb.ex.configs.length;
531
+ const na = ra.ex.configs.length;
532
+ if (na !== nb || ra.ex.starts !== rb.ex.starts || ra.moves !== rb.moves) {
533
+ const starts = ra.ex.starts === rb.ex.starts ? `${ra.ex.starts}` : `${rb.ex.starts} → ${ra.ex.starts}`;
534
+ const say = na === nb && ra.ex.starts === rb.ex.starts
535
+ ? `The same ${na} situations, with ${ra.moves} moves between them (was ${rb.moves}): what changed connects situations that were already reachable.`
536
+ : `Situations the model can reach: ${nb} → ${na}, from ${starts} starting situations; moves between them: ${rb.moves} → ${ra.moves}.`;
537
+ shift('situations', 'situations', { situations: nb, starts: rb.ex.starts, moves: rb.moves }, { situations: na, starts: ra.ex.starts, moves: ra.moves }, say);
538
+ }
539
+ return out;
540
+ }
541
+ // the sentence for a revision that changes nothing a person can observe
542
+ export function flowSameBehaviour(r) {
543
+ const ends = r.exits.size;
544
+ const guarantees = (r.g.flow.assertions ?? []).length;
545
+ const parts = [ends > 0 ? `the same ${ends === 1 ? 'ending is' : `${ends} endings are`} reached the same way` : '', guarantees > 0 ? `the ${guarantees} guarantees give the same result` : '', r.main !== null ? 'the main story is the same' : ''].filter((x) => x !== '');
546
+ return `Nothing: ${parts.length > 0 ? `${joinWords(parts)}, ` : ''}in the same ${r.ex.configs.length} situations.`;
547
+ }