@tanishqkancharla/diffmap 0.4.0 → 0.6.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.
@@ -22,9 +22,9 @@ function brokenMermaidSpec(body = "flwochart TD\n A --> B") {
22
22
  return `# Broken mermaid\n\n\`\`\`mermaid\n${body}\n\`\`\`\n`;
23
23
  }
24
24
 
25
- function parseServicesSpec(body: string[]) {
25
+ function parseServicesSpec(body: string[], info = "services") {
26
26
  return parseViewerDocument(
27
- ["# Flow", "", "```services", ...body, "```", ""].join("\n"),
27
+ ["# Flow", "", `\`\`\`${info}`, ...body, "```", ""].join("\n"),
28
28
  "spec.md",
29
29
  );
30
30
  }
@@ -196,7 +196,7 @@ test("parseViewerDocument reads commit references and changes fences", () => {
196
196
  test("parseViewerDocument reads code highlights and trailing notes", () => {
197
197
  const document = parseViewerDocument(
198
198
  [
199
- "```py {2}",
199
+ "```py [src/save.py] {2}",
200
200
  "def save(record):",
201
201
  " storage.save(record)",
202
202
  "",
@@ -225,6 +225,7 @@ test("parseViewerDocument reads code highlights and trailing notes", () => {
225
225
  text: "Writes `record` to disk.",
226
226
  },
227
227
  ],
228
+ title: "src/save.py",
228
229
  },
229
230
  });
230
231
  // Symbols match whole words, so `save` is not found inside `saveAll`.
@@ -243,7 +244,7 @@ test("parseViewerDocument reads code highlights and trailing notes", () => {
243
244
  // Call stacks number rows, and a symbol must appear in visible row text.
244
245
  const stack = parseViewerDocument(
245
246
  [
246
- "```callstack {2}",
247
+ "```callstack {2} [Save flow]",
247
248
  " run() [[src/a.ts#run]]",
248
249
  "+└── save(record)",
249
250
  "# [!note save] Writes the record.",
@@ -258,6 +259,17 @@ test("parseViewerDocument reads code highlights and trailing notes", () => {
258
259
  if (fence?.kind !== "callstack") return;
259
260
  assert.equal(fence.lines.length, 2);
260
261
  assert.deepEqual(fence.highlights, [2]);
262
+ assert.equal(fence.title, "Save flow");
263
+ const mdc = parseViewerDocument(
264
+ '::callstack{title="Save flow"}\n run()\n└── save(record)\n::\n',
265
+ );
266
+ if (mdc instanceof Error) throw mdc;
267
+ assert.deepEqual(
268
+ mdc.nodes[0]?.type === "view" && mdc.nodes[0].fence.kind === "callstack"
269
+ ? mdc.nodes[0].fence.title
270
+ : undefined,
271
+ "Save flow",
272
+ );
261
273
  assert.deepEqual(fence.notes, [
262
274
  { target: { kind: "symbol", symbol: "save" }, text: "Writes the record." },
263
275
  ]);
@@ -284,6 +296,8 @@ test("parseViewerDocument reads services, steps, and their references", () => {
284
296
  "service Store",
285
297
  " state rows = none",
286
298
  "",
299
+ 'start "Alice has not logged in"',
300
+ " > No rows yet.",
287
301
  'step "Verify"',
288
302
  " API -> Auth: verify(alice) [[src/api.ts#login]]",
289
303
  " > Sends the password hash.",
@@ -318,6 +332,10 @@ test("parseViewerDocument reads services, steps, and their references", () => {
318
332
  );
319
333
  // A description belongs to the line above it and joins continued lines.
320
334
  assert.equal(diagram.services[0]!.description, "Routes requests.");
335
+ assert.deepEqual(diagram.start, {
336
+ caption: "Alice has not logged in",
337
+ description: "No rows yet.",
338
+ });
321
339
  assert.equal(
322
340
  diagram.services[0]!.children[0]!.state[0]!.description,
323
341
  "Failures since the last success. Five locks the account.",
@@ -353,6 +371,122 @@ test("parseViewerDocument reads services, steps, and their references", () => {
353
371
  ]);
354
372
  });
355
373
 
374
+ test("a services diff replays Today and Proposed separately", () => {
375
+ const document = parseViewerDocument(
376
+ [
377
+ "```services diff",
378
+ " service API",
379
+ "- state count = 0",
380
+ "+ state count = 1",
381
+ "-service Cache",
382
+ " state hits = 0",
383
+ "+service Queue",
384
+ "+ state jobs = 0",
385
+ "",
386
+ ' step "Handle"',
387
+ "- API -> Cache: get",
388
+ "+ API -> Queue: push",
389
+ "- Cache.hits = 1",
390
+ "+ Queue.jobs = 1",
391
+ " API.count = 5",
392
+ '-step "Old cleanup"',
393
+ " API.count = 9",
394
+ "```",
395
+ "",
396
+ ].join("\n"),
397
+ );
398
+ if (document instanceof Error) throw document;
399
+ const fence = document.nodes[0];
400
+ if (fence?.type !== "view" || fence.fence.kind !== "services")
401
+ throw new Error("expected a services fence");
402
+ if (!fence.fence.parsed.ok) throw new Error(fence.fence.parsed.reason);
403
+ const diagram = fence.fence.parsed.diagram;
404
+ // Unmarked lines inside a removed service or step take its sign.
405
+ assert.equal(diagram.services[1]!.state[0]!.sign, "-");
406
+ assert.equal(diagram.steps[1]!.updates[0]!.sign, "-");
407
+ assert.deepEqual(
408
+ diagram.steps[0]!.events.map((event) => event.sign),
409
+ ["-", "+"],
410
+ );
411
+ const at = (step: number, side: "today" | "proposed") =>
412
+ Object.fromEntries(
413
+ [...serviceStateAt(diagram, step, side)].map(([name, state]) => [
414
+ name,
415
+ Object.fromEntries(state),
416
+ ]),
417
+ );
418
+ // A - and a + declaration of one key give each side its own start value.
419
+ assert.deepEqual(at(0, "today"), {
420
+ API: { count: "0" },
421
+ Cache: { hits: "0" },
422
+ Queue: {},
423
+ });
424
+ assert.deepEqual(at(0, "proposed"), {
425
+ API: { count: "1" },
426
+ Cache: {},
427
+ Queue: { jobs: "0" },
428
+ });
429
+ assert.deepEqual(at(2, "today"), {
430
+ API: { count: "9" },
431
+ Cache: { hits: "1" },
432
+ Queue: {},
433
+ });
434
+ assert.deepEqual(at(2, "proposed"), {
435
+ API: { count: "5" },
436
+ Cache: {},
437
+ Queue: { jobs: "1" },
438
+ });
439
+ });
440
+
441
+ test("a services diff rejects unmarked lines and contradictory signs", () => {
442
+ assert.match(
443
+ String(
444
+ parseServicesSpec([" service API", "service Store"], "services diff"),
445
+ ),
446
+ /line 5\): Start every line of a services diff/,
447
+ );
448
+ assert.match(
449
+ String(
450
+ parseServicesSpec(
451
+ [" service API", '-step "Old"', "+ API -> API: new"],
452
+ "services diff",
453
+ ),
454
+ ),
455
+ /line 6\): A \+ line can't sit inside a removed step/,
456
+ );
457
+ });
458
+
459
+ test("a mermaid diff keeps every line and splits out each side", () => {
460
+ const document = parseViewerDocument(
461
+ [
462
+ "```mermaid diff [Request flow]",
463
+ " flowchart LR",
464
+ "- A --> B[Old]",
465
+ "+ A --> B[New]",
466
+ "+ A --> C",
467
+ " %% ref node:A [[src/a.ts]]",
468
+ "```",
469
+ "",
470
+ ].join("\n"),
471
+ );
472
+ if (document instanceof Error) throw document;
473
+ const fence = document.nodes[0];
474
+ if (fence?.type !== "view" || fence.fence.kind !== "mermaid")
475
+ throw new Error("expected a mermaid fence");
476
+ // Mermaid keeps a node's first label, so a change's + lines come first.
477
+ assert.equal(
478
+ fence.fence.source,
479
+ "flowchart LR\n A --> B[New]\n A --> C\n A --> B[Old]\n %% ref node:A [[src/a.ts]]",
480
+ );
481
+ assert.deepEqual(fence.fence.sides, {
482
+ today: "flowchart LR\n A --> B[Old]\n %% ref node:A [[src/a.ts]]",
483
+ proposed:
484
+ "flowchart LR\n A --> B[New]\n A --> C\n %% ref node:A [[src/a.ts]]",
485
+ });
486
+ assert.equal(fence.fence.annotations.length, 1);
487
+ assert.equal(fence.fence.title, "Request flow");
488
+ });
489
+
356
490
  test("parseViewerDocument fails on services errors with the file line", () => {
357
491
  const unknown = parseServicesSpec([
358
492
  "service API",
@@ -370,6 +504,15 @@ test("parseViewerDocument fails on services errors with the file line", () => {
370
504
  assert.match(String(undeclared), /line 6\): API has no state count/);
371
505
  const uncaptioned = parseServicesSpec(["service API", "step"]);
372
506
  assert.match(String(uncaptioned), /line 5\): Give the step a caption/);
507
+ const lateStart = parseServicesSpec([
508
+ "service API",
509
+ 'step "Go"',
510
+ 'start "Too late"',
511
+ ]);
512
+ assert.match(
513
+ String(lateStart),
514
+ /line 6\): Write one start line, before the first step/,
515
+ );
373
516
  const orphan = parseServicesSpec(["service API", "> Not indented."]);
374
517
  assert.match(String(orphan), /line 5\): Put a > line directly below/);
375
518
  });
@@ -84,6 +84,8 @@ export type ViewerElementAttrs = {
84
84
  task: boolean | undefined;
85
85
  alertType: string | undefined;
86
86
  align: string | undefined;
87
+ /** The label of an MDC `::details{summary="…"}` toggle. */
88
+ summary: string | undefined;
87
89
  };
88
90
 
89
91
  export function parseViewerDocument(source: string, path = "spec") {
@@ -162,12 +164,21 @@ export function parseViewerDocument(source: string, path = "spec") {
162
164
  );
163
165
  for (const [offset, fence] of mermaidFences.entries()) {
164
166
  const mermaidIndex = offset + 1;
165
- const parsedMermaid = parseMermaidDiagram({
166
- source: fence.source,
167
- path,
168
- where: mermaidWhere(source, mermaidIndex),
169
- });
170
- if (parsedMermaid instanceof Error) return parsedMermaid;
167
+ const where = mermaidWhere(source, mermaidIndex);
168
+ // A diff draws every line, and each side must also stand on its own.
169
+ for (const [side, diagram] of [
170
+ ["", fence.source],
171
+ [", Today side", fence.sides?.today],
172
+ [", Proposed side", fence.sides?.proposed],
173
+ ] as const) {
174
+ if (diagram === undefined) continue;
175
+ const parsedMermaid = parseMermaidDiagram({
176
+ source: diagram,
177
+ path,
178
+ where: `${where}${side}`,
179
+ });
180
+ if (parsedMermaid instanceof Error) return parsedMermaid;
181
+ }
171
182
  const targets = new Set<string>();
172
183
  for (const link of fence.annotations) {
173
184
  const target = `${link.target}:${link.id}`;
@@ -374,14 +385,29 @@ function fromElement(
374
385
  if (tag === "template")
375
386
  return children.flatMap((child) => fromNode(child, literalChildren));
376
387
  if (tag === "pre") {
388
+ // md4x parses ```ts [src/request.ts] into `filename`; diffmap shows it as the title.
377
389
  return fenceView(
378
390
  stringAttr(element, "language"),
379
391
  comarkText(children),
380
392
  highlightsAttr(element),
393
+ stringAttr(element, "filename"),
394
+ stringAttr(element, "meta"),
381
395
  );
382
396
  }
383
- if (tag === "mermaid") return fenceView("mermaid", comarkText(children));
384
- if (tag === "callstack") return fenceView("callstack", comarkText(children));
397
+ if (tag === "mermaid")
398
+ return fenceView(
399
+ "mermaid",
400
+ comarkText(children),
401
+ undefined,
402
+ stringAttr(element, "title"),
403
+ );
404
+ if (tag === "callstack")
405
+ return fenceView(
406
+ "callstack",
407
+ comarkText(children),
408
+ undefined,
409
+ stringAttr(element, "title"),
410
+ );
385
411
  if (tag === "changes") {
386
412
  const rev = stringAttr(element, "rev");
387
413
  const body = comarkText(children);
@@ -425,10 +451,18 @@ function fenceView(
425
451
  lang: string | undefined,
426
452
  source: string,
427
453
  highlights?: number[],
454
+ title?: string,
455
+ meta?: string,
428
456
  ): ViewerView {
429
457
  return {
430
458
  type: "view",
431
- fence: parseFence(lang === undefined ? "" : lang, source, highlights),
459
+ fence: parseFence(
460
+ lang === undefined ? "" : lang,
461
+ source,
462
+ highlights,
463
+ title,
464
+ meta,
465
+ ),
432
466
  };
433
467
  }
434
468
 
@@ -479,6 +513,7 @@ function elementAttrs(element: ComarkElement): ViewerElementAttrs {
479
513
  task: element[1].task === true ? true : undefined,
480
514
  alertType: stringAttr(element, "type"),
481
515
  align: stringAttr(element, "align"),
516
+ summary: stringAttr(element, "summary"),
482
517
  };
483
518
  }
484
519
 
package/src/serve.test.ts CHANGED
@@ -7,7 +7,7 @@ import path from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { promisify } from "node:util";
9
9
  import { setTimeout as delay } from "node:timers/promises";
10
- import test from "node:test";
10
+ import test, { before } from "node:test";
11
11
  import { DiffmapServeError } from "./errors.js";
12
12
  import { startServer, type DiffmapServer } from "./serve.js";
13
13
 
@@ -92,6 +92,27 @@ async function waitForClosed(server: DiffmapServer, timeoutMs = 10_000) {
92
92
  ]);
93
93
  }
94
94
 
95
+ // These tests run several servers at once. On a cold or stale cache each one
96
+ // prebundles dependencies on its own, at over 1 GB each, which can exhaust the
97
+ // machine's memory. Vite holds requests for a prebundled file until the bundle
98
+ // is on disk, so fetching one here leaves a warm cache for the servers below.
99
+ before(async () => {
100
+ const server = assertStarted(
101
+ await startServer({ filePath: sampleSpec, workspaceRoot: packageRoot }),
102
+ );
103
+ try {
104
+ const entry = await fetch(`${server.url}/src/viewer/main.tsx`);
105
+ const dep = /"(\/node_modules\/\.vite\/deps\/[^"]+)"/.exec(
106
+ await entry.text(),
107
+ )?.[1];
108
+ assert.ok(dep !== undefined, "viewer entry imports no prebundled module");
109
+ assert.equal((await fetch(`${server.url}${dep}`)).ok, true);
110
+ } finally {
111
+ await server.shutdown();
112
+ await waitForClosed(server);
113
+ }
114
+ });
115
+
95
116
  test(
96
117
  "startServer binds distinct free ports when 4177 is taken",
97
118
  { timeout: 90_000 },
package/src/services.ts CHANGED
@@ -7,9 +7,22 @@ import { parseAnnotation, type SourceAnnotation } from "./annotations.js";
7
7
  */
8
8
  export type ServiceDiagram = {
9
9
  services: Service[];
10
+ /** From `start "caption"`: what the declared state shows, before step 1. */
11
+ start: { caption: string; description: string | undefined } | undefined;
10
12
  steps: ServiceStep[];
13
+ /** From ```services diff: items carry `+` or `-` signs. */
14
+ diff: boolean;
11
15
  };
12
16
 
17
+ /**
18
+ * In a `services diff`, `-` marks an item that exists only Today and `+` one
19
+ * that exists only in the Proposed version. Items inherit the sign of the
20
+ * service or step they sit in.
21
+ */
22
+ export type Sign = "+" | "-" | " ";
23
+
24
+ export type Side = "today" | "proposed";
25
+
13
26
  export type Service = {
14
27
  name: string;
15
28
  label: string | undefined;
@@ -17,12 +30,17 @@ export type Service = {
17
30
  state: ServiceState[];
18
31
  children: Service[];
19
32
  annotation: SourceAnnotation | undefined;
33
+ sign: Sign;
20
34
  };
21
35
 
22
36
  export type ServiceState = {
23
37
  key: string;
38
+ /** The Proposed initial value. */
24
39
  value: string;
40
+ /** Set when a `-` and a `+` line declare the key with different values. */
41
+ todayValue: string | undefined;
25
42
  description: string | undefined;
43
+ sign: Sign;
26
44
  };
27
45
 
28
46
  export type ServiceStep = {
@@ -31,6 +49,7 @@ export type ServiceStep = {
31
49
  description: string | undefined;
32
50
  events: ServiceEvent[];
33
51
  updates: ServiceUpdate[];
52
+ sign: Sign;
34
53
  };
35
54
 
36
55
  export type ServiceEvent = {
@@ -40,6 +59,7 @@ export type ServiceEvent = {
40
59
  /** Shown beside the event's label while its step is current. */
41
60
  note: string | undefined;
42
61
  annotation: SourceAnnotation | undefined;
62
+ sign: Sign;
43
63
  };
44
64
 
45
65
  export type ServiceUpdate = {
@@ -48,6 +68,7 @@ export type ServiceUpdate = {
48
68
  value: string;
49
69
  /** Shown beside the changed state while its step is current. */
50
70
  note: string | undefined;
71
+ sign: Sign;
51
72
  };
52
73
 
53
74
  export type ServicesParse =
@@ -60,6 +81,7 @@ const serviceLine = new RegExp(
60
81
  );
61
82
  const stateLine = new RegExp(String.raw`^state\s+(${name})\s*=\s*(.+)$`);
62
83
  const stepLine = /^step\b(?:\s+"([^"]*)")?\s*$/;
84
+ const startLine = /^start\b(?:\s+"([^"]*)")?\s*$/;
63
85
  const eventLine = new RegExp(
64
86
  String.raw`^(${name})\s*->\s*(${name})(?:\s*:\s*(.*))?$`,
65
87
  );
@@ -77,10 +99,14 @@ const updateLine = new RegExp(String.raw`^(${name})\.(${name})\s*=\s*(.+)$`);
77
99
  * Auth.attempts = 1
78
100
  * ```
79
101
  */
80
- export function parseServiceDiagram(source: string): ServicesParse {
102
+ export function parseServiceDiagram(
103
+ source: string,
104
+ diff = false,
105
+ ): ServicesParse {
81
106
  const services: Service[] = [];
82
107
  const byName = new Map<string, Service>();
83
108
  const steps: ServiceStep[] = [];
109
+ let start: ServiceDiagram["start"];
84
110
  // Open services, innermost last, with the indent of their `service` line.
85
111
  const open: { indent: number; service: Service }[] = [];
86
112
  // Where a `>` line's text goes: a description on a service, state, or
@@ -104,16 +130,35 @@ export function parseServiceDiagram(source: string): ServicesParse {
104
130
  };
105
131
  };
106
132
  const lines = source.split("\n");
107
- for (const [index, raw] of lines.entries()) {
133
+ for (const [index, row] of lines.entries()) {
108
134
  const line = index + 1;
109
135
  const fail = (reason: string): ServicesParse => ({
110
136
  ok: false,
111
137
  line,
112
138
  reason,
113
139
  });
140
+ if (row.trim().length === 0 || row.trim().startsWith("%%")) continue;
141
+ // A diff's first column is its sign, so indentation counts from column 1.
142
+ if (diff && !/^[+ -]/.test(row))
143
+ return fail("Start every line of a services diff with +, -, or a space.");
144
+ // SAFETY: the test above limits a diff row's first character to a Sign.
145
+ const own: Sign = diff ? (row[0] as Sign) : " ";
146
+ const raw = diff ? row.slice(1) : row;
114
147
  const text = raw.trim();
115
- if (text.length === 0 || text.startsWith("%%")) continue;
148
+ if (text.length === 0) continue;
116
149
  const indent = raw.replace(/\t/g, " ").search(/\S/);
150
+ // An item takes the sign of the service or step it sits in.
151
+ const signWithin = (
152
+ container: { sign: Sign } | undefined,
153
+ kind: string,
154
+ ) => {
155
+ const outer = container?.sign ?? " ";
156
+ if (outer === " " || own === " " || own === outer)
157
+ return outer === " " ? own : outer;
158
+ return fail(
159
+ `A ${own} line can't sit inside a ${outer === "-" ? "removed" : "added"} ${kind}.`,
160
+ );
161
+ };
117
162
  if (text.startsWith(">")) {
118
163
  if (described === undefined || indent <= described.indent)
119
164
  return fail(
@@ -135,6 +180,8 @@ export function parseServiceDiagram(source: string): ServicesParse {
135
180
  if (byName.has(serviceName))
136
181
  return fail(`Service ${serviceName} is declared twice.`);
137
182
  while (open.length > 0 && open.at(-1)!.indent >= indent) open.pop();
183
+ const sign = signWithin(open.at(-1)?.service, "service");
184
+ if (typeof sign !== "string") return sign;
138
185
  const service: Service = {
139
186
  name: serviceName,
140
187
  label: serviceMatch[2],
@@ -145,6 +192,7 @@ export function parseServiceDiagram(source: string): ServicesParse {
145
192
  annotation === undefined
146
193
  ? undefined
147
194
  : { ...annotation, text: serviceName },
195
+ sign,
148
196
  };
149
197
  (open.at(-1)?.service.children ?? services).push(service);
150
198
  byName.set(serviceName, service);
@@ -164,18 +212,54 @@ export function parseServiceDiagram(source: string): ServicesParse {
164
212
  if (owner === undefined)
165
213
  return fail("Indent state under the service that owns it.");
166
214
  const key = stateMatch[1]!;
167
- if (owner.state.some((entry) => entry.key === key))
168
- return fail(`${owner.name}.${key} is declared twice.`);
169
- const entry = {
215
+ const value = stateMatch[2]!.trim();
216
+ const sign = signWithin(owner, "service");
217
+ if (typeof sign !== "string") return sign;
218
+ const declared = owner.state.find((entry) => entry.key === key);
219
+ if (declared !== undefined) {
220
+ // A `-` and a `+` declaration of one key change its initial value.
221
+ if (
222
+ declared.todayValue !== undefined ||
223
+ declared.sign === " " ||
224
+ sign === " " ||
225
+ declared.sign === sign
226
+ )
227
+ return fail(`${owner.name}.${key} is declared twice.`);
228
+ declared.todayValue = declared.sign === "-" ? declared.value : value;
229
+ declared.value = declared.sign === "+" ? declared.value : value;
230
+ declared.sign = " ";
231
+ describe(indent, declared);
232
+ continue;
233
+ }
234
+ const entry: ServiceState = {
170
235
  key,
171
- value: stateMatch[2]!.trim(),
236
+ value,
237
+ todayValue: undefined,
172
238
  description: undefined,
239
+ sign,
173
240
  };
174
241
  owner.state.push(entry);
175
242
  describe(indent, entry);
176
243
  continue;
177
244
  }
178
245
 
246
+ const startMatch = startLine.exec(text);
247
+ if (startMatch !== null) {
248
+ if (indent !== 0) return fail("Start the start line at column 0.");
249
+ if (step !== undefined || start !== undefined)
250
+ return fail("Write one start line, before the first step.");
251
+ if (own !== " ")
252
+ return fail("The start line describes both versions; drop its sign.");
253
+ const caption = startMatch[1]?.trim();
254
+ if (caption === undefined || caption === "")
255
+ return fail(
256
+ 'Give the start a caption that says what the initial state shows, like start "Alice is signed out".',
257
+ );
258
+ start = { caption, description: undefined };
259
+ describe(indent, start);
260
+ continue;
261
+ }
262
+
179
263
  const stepMatch = stepLine.exec(text);
180
264
  if (stepMatch !== null) {
181
265
  if (indent !== 0) return fail("Start step lines at column 0.");
@@ -189,6 +273,7 @@ export function parseServiceDiagram(source: string): ServicesParse {
189
273
  description: undefined,
190
274
  events: [],
191
275
  updates: [],
276
+ sign: own,
192
277
  };
193
278
  steps.push(next);
194
279
  describe(indent, next);
@@ -201,6 +286,8 @@ export function parseServiceDiagram(source: string): ServicesParse {
201
286
  for (const service of [event[1]!, event[2]!])
202
287
  if (!byName.has(service)) return fail(`Unknown service ${service}.`);
203
288
  const label = (event[3] ?? "").trim();
289
+ const sign = signWithin(step, "step");
290
+ if (typeof sign !== "string") return sign;
204
291
  const entry: ServiceEvent = {
205
292
  from: event[1]!,
206
293
  to: event[2]!,
@@ -213,6 +300,7 @@ export function parseServiceDiagram(source: string): ServicesParse {
213
300
  ...annotation,
214
301
  text: `${event[1]} → ${event[2]}${label === "" ? "" : `: ${label}`}`,
215
302
  },
303
+ sign,
216
304
  };
217
305
  step.events.push(entry);
218
306
  annotate(indent, entry);
@@ -226,11 +314,14 @@ export function parseServiceDiagram(source: string): ServicesParse {
226
314
  return fail(
227
315
  `${owner.name} has no state ${update[2]}. Declare it with state ${update[2]} = <initial value>.`,
228
316
  );
317
+ const sign = signWithin(step, "step");
318
+ if (typeof sign !== "string") return sign;
229
319
  const entry: ServiceUpdate = {
230
320
  service: owner.name,
231
321
  key: update[2]!,
232
322
  value: update[3]!.trim(),
233
323
  note: undefined,
324
+ sign,
234
325
  };
235
326
  step.updates.push(entry);
236
327
  annotate(indent, entry);
@@ -242,12 +333,12 @@ export function parseServiceDiagram(source: string): ServicesParse {
242
333
  }
243
334
 
244
335
  return fail(
245
- `Expected service <Name>, state <key> = <value>, or step "<caption>", got "${text}".`,
336
+ `Expected service <Name>, state <key> = <value>, start "<caption>", or step "<caption>", got "${text}".`,
246
337
  );
247
338
  }
248
339
  if (services.length === 0)
249
340
  return { ok: false, line: 1, reason: "Declare at least one service." };
250
- return { ok: true, diagram: { services, steps } };
341
+ return { ok: true, diagram: { services, start, steps, diff } };
251
342
  }
252
343
 
253
344
  /** Every service in declaration order, with its parent. */
@@ -261,17 +352,33 @@ export function flattenServices(
261
352
  ]);
262
353
  }
263
354
 
264
- /** State after the first `step` steps; step 0 is the declared state. */
265
- export function serviceStateAt(diagram: ServiceDiagram, step: number) {
355
+ /**
356
+ * State after the first `step` steps; step 0 is the declared state. In a
357
+ * diff, Today skips `+` items and Proposed skips `-` items.
358
+ */
359
+ export function serviceStateAt(
360
+ diagram: ServiceDiagram,
361
+ step: number,
362
+ side: Side = "proposed",
363
+ ) {
364
+ const skip: Sign = side === "today" ? "+" : "-";
266
365
  const state = new Map(
267
366
  flattenServices(diagram.services).map(({ service }) => [
268
367
  service.name,
269
- new Map(service.state.map((entry) => [entry.key, entry.value])),
368
+ new Map(
369
+ service.state
370
+ .filter((entry) => entry.sign !== skip)
371
+ .map((entry) => [
372
+ entry.key,
373
+ side === "today" ? (entry.todayValue ?? entry.value) : entry.value,
374
+ ]),
375
+ ),
270
376
  ]),
271
377
  );
272
378
  for (const { updates } of diagram.steps.slice(0, step))
273
379
  for (const update of updates)
274
- state.get(update.service)!.set(update.key, update.value);
380
+ if (update.sign !== skip)
381
+ state.get(update.service)!.set(update.key, update.value);
275
382
  return state;
276
383
  }
277
384