@tanishqkancharla/diffmap 0.3.0 → 0.4.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.
@@ -6,8 +6,9 @@ import path from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { promisify } from "node:util";
8
8
  import test from "node:test";
9
- import { DiffmapMermaidError } from "./errors.js";
9
+ import { DiffmapMermaidError, DiffmapServicesError } from "./errors.js";
10
10
  import { parseViewerDocument } from "./parseViewer.js";
11
+ import { serviceStateAt } from "./services.js";
11
12
  import { shareMarkdownFile } from "./share.js";
12
13
  import { startServer } from "./serve.js";
13
14
 
@@ -21,6 +22,17 @@ function brokenMermaidSpec(body = "flwochart TD\n A --> B") {
21
22
  return `# Broken mermaid\n\n\`\`\`mermaid\n${body}\n\`\`\`\n`;
22
23
  }
23
24
 
25
+ function parseServicesSpec(body: string[]) {
26
+ return parseViewerDocument(
27
+ ["# Flow", "", "```services", ...body, "```", ""].join("\n"),
28
+ "spec.md",
29
+ );
30
+ }
31
+
32
+ function codeFenceProblem(lines: string[]) {
33
+ return String(parseViewerDocument(["```ts", ...lines, "```", ""].join("\n")));
34
+ }
35
+
24
36
  async function withTempSpec(
25
37
  source: string,
26
38
  run: (filePath: string) => Promise<void>,
@@ -128,13 +140,30 @@ test("parseViewerDocument reads commit references and changes fences", () => {
128
140
  "```changes",
129
141
  "```",
130
142
  "",
143
+ "```changes",
144
+ "a1b2c3d",
145
+ "src/store.ts",
146
+ " src/request.ts ",
147
+ "```",
148
+ "",
149
+ "```changes",
150
+ "",
151
+ "src/store.ts",
152
+ "```",
153
+ "",
131
154
  "See [[a1b2c3d:src/store.ts#L12-L30]] and [[src/store.ts#L3]].",
132
155
  "",
133
156
  ].join("\n"),
134
157
  );
135
158
  assert.equal(document instanceof Error, false);
136
159
  if (document instanceof Error) return;
137
- assert.deepEqual(document.changeRevs, ["a1b2c3d..e4f5a6b", "working"]);
160
+ // Lines after the rev list files to review in order; a blank rev is uncommitted.
161
+ assert.deepEqual(document.changeCards, [
162
+ { rev: "a1b2c3d..e4f5a6b", review: [] },
163
+ { rev: "working", review: [] },
164
+ { rev: "a1b2c3d", review: ["src/store.ts", "src/request.ts"] },
165
+ { rev: "working", review: ["src/store.ts"] },
166
+ ]);
138
167
  assert.deepEqual(document.references, [
139
168
  { kind: "file", rev: "a1b2c3d", path: "src/store.ts", start: 12, end: 30 },
140
169
  { kind: "file", path: "src/store.ts", start: 3, end: 3 },
@@ -149,6 +178,10 @@ test("parseViewerDocument reads commit references and changes fences", () => {
149
178
  parseViewerDocument("```changes\nmain\n```\n") instanceof Error,
150
179
  true,
151
180
  );
181
+ assert.match(
182
+ String(parseViewerDocument("```changes\na1b2c3d\na.ts\na.ts\n```\n")),
183
+ /lists a\.ts twice/,
184
+ );
152
185
  // The removed patch fence fails instead of rendering as code.
153
186
  assert.match(
154
187
  String(
@@ -160,6 +193,187 @@ test("parseViewerDocument reads commit references and changes fences", () => {
160
193
  );
161
194
  });
162
195
 
196
+ test("parseViewerDocument reads code highlights and trailing notes", () => {
197
+ const document = parseViewerDocument(
198
+ [
199
+ "```py {2}",
200
+ "def save(record):",
201
+ " storage.save(record)",
202
+ "",
203
+ "# [!note L1-L2] Saves the record.",
204
+ "# [!note storage.save] Writes `record` to disk.",
205
+ "```",
206
+ "",
207
+ ].join("\n"),
208
+ );
209
+ assert.equal(document instanceof Error, false);
210
+ if (document instanceof Error) return;
211
+ assert.deepEqual(document.nodes[0], {
212
+ type: "view",
213
+ fence: {
214
+ kind: "code",
215
+ lang: "py",
216
+ source: "def save(record):\n storage.save(record)",
217
+ highlights: [2],
218
+ notes: [
219
+ {
220
+ target: { kind: "lines", start: 1, end: 2 },
221
+ text: "Saves the record.",
222
+ },
223
+ {
224
+ target: { kind: "symbol", symbol: "storage.save" },
225
+ text: "Writes `record` to disk.",
226
+ },
227
+ ],
228
+ },
229
+ });
230
+ // Symbols match whole words, so `save` is not found inside `saveAll`.
231
+ assert.match(
232
+ codeFenceProblem(["store.saveAll(x);", "// [!note save] Saves."]),
233
+ /"save" does not appear/,
234
+ );
235
+ assert.match(
236
+ codeFenceProblem(["const x = 1;", "// [!note L1-L2] Too long."]),
237
+ /outside the 1-line code fence/,
238
+ );
239
+ assert.match(
240
+ String(parseViewerDocument("```ts {3}\nconst x = 1;\n```\n")),
241
+ /Highlighted line 3 is outside/,
242
+ );
243
+ // Call stacks number rows, and a symbol must appear in visible row text.
244
+ const stack = parseViewerDocument(
245
+ [
246
+ "```callstack {2}",
247
+ " run() [[src/a.ts#run]]",
248
+ "+└── save(record)",
249
+ "# [!note save] Writes the record.",
250
+ "```",
251
+ "",
252
+ ].join("\n"),
253
+ );
254
+ if (stack instanceof Error) throw stack;
255
+ const fence =
256
+ stack.nodes[0]?.type === "view" ? stack.nodes[0].fence : undefined;
257
+ assert.equal(fence?.kind, "callstack");
258
+ if (fence?.kind !== "callstack") return;
259
+ assert.equal(fence.lines.length, 2);
260
+ assert.deepEqual(fence.highlights, [2]);
261
+ assert.deepEqual(fence.notes, [
262
+ { target: { kind: "symbol", symbol: "save" }, text: "Writes the record." },
263
+ ]);
264
+ assert.match(
265
+ String(
266
+ parseViewerDocument(
267
+ "```callstack\n run() [[src/a.ts#Store.save]]\n# [!note save] Hidden.\n```\n",
268
+ ),
269
+ ),
270
+ /"save" does not appear/,
271
+ );
272
+ });
273
+
274
+ test("parseViewerDocument reads services, steps, and their references", () => {
275
+ const document = parseViewerDocument(
276
+ [
277
+ "```services",
278
+ "service API [[src/api.ts]]",
279
+ " > Routes requests.",
280
+ " service Auth",
281
+ " state attempts = 0",
282
+ " > Failures since the last success.",
283
+ " > Five locks the account.",
284
+ "service Store",
285
+ " state rows = none",
286
+ "",
287
+ 'step "Verify"',
288
+ " API -> Auth: verify(alice) [[src/api.ts#login]]",
289
+ " > Sends the password hash.",
290
+ " Auth.attempts = 1",
291
+ " > One failure so far.",
292
+ 'step "Auth stores the row"',
293
+ " Auth -> Store: insert",
294
+ " Store.rows = 1 row",
295
+ " Auth.attempts = 0",
296
+ "```",
297
+ "",
298
+ ].join("\n"),
299
+ );
300
+ assert.equal(document instanceof Error, false);
301
+ if (document instanceof Error) return;
302
+ const fence = document.nodes[0];
303
+ assert.equal(fence?.type === "view" && fence.fence.kind, "services");
304
+ if (fence?.type !== "view" || fence.fence.kind !== "services") return;
305
+ assert.equal(fence.fence.parsed.ok, true);
306
+ if (!fence.fence.parsed.ok) return;
307
+ const diagram = fence.fence.parsed.diagram;
308
+ // Dedenting after Auth's state closes both Auth and API.
309
+ assert.deepEqual(
310
+ diagram.services.map((service) => [
311
+ service.name,
312
+ service.children.map((child) => child.name),
313
+ ]),
314
+ [
315
+ ["API", ["Auth"]],
316
+ ["Store", []],
317
+ ],
318
+ );
319
+ // A description belongs to the line above it and joins continued lines.
320
+ assert.equal(diagram.services[0]!.description, "Routes requests.");
321
+ assert.equal(
322
+ diagram.services[0]!.children[0]!.state[0]!.description,
323
+ "Failures since the last success. Five locks the account.",
324
+ );
325
+ assert.equal(diagram.steps[0]!.events[0]!.note, "Sends the password hash.");
326
+ assert.equal(diagram.steps[0]!.updates[0]!.note, "One failure so far.");
327
+ assert.equal(diagram.steps[1]!.updates[0]!.note, undefined);
328
+ const at = (step: number) =>
329
+ Object.fromEntries(
330
+ [...serviceStateAt(diagram, step)].map(([name, state]) => [
331
+ name,
332
+ Object.fromEntries(state),
333
+ ]),
334
+ );
335
+ assert.deepEqual(at(0), {
336
+ API: {},
337
+ Auth: { attempts: "0" },
338
+ Store: { rows: "none" },
339
+ });
340
+ assert.deepEqual(at(1), {
341
+ API: {},
342
+ Auth: { attempts: "1" },
343
+ Store: { rows: "none" },
344
+ });
345
+ assert.deepEqual(at(2), {
346
+ API: {},
347
+ Auth: { attempts: "0" },
348
+ Store: { rows: "1 row" },
349
+ });
350
+ assert.deepEqual(document.references, [
351
+ { kind: "file", path: "src/api.ts" },
352
+ { kind: "file", path: "src/api.ts", symbol: "login" },
353
+ ]);
354
+ });
355
+
356
+ test("parseViewerDocument fails on services errors with the file line", () => {
357
+ const unknown = parseServicesSpec([
358
+ "service API",
359
+ "",
360
+ 'step "Say hi"',
361
+ " API -> Auth: hi",
362
+ ]);
363
+ assert.equal(unknown instanceof DiffmapServicesError, true);
364
+ assert.match(String(unknown), /spec\.md \(line 7\): Unknown service Auth/);
365
+ const undeclared = parseServicesSpec([
366
+ "service API",
367
+ 'step "Count"',
368
+ " API.count = 1",
369
+ ]);
370
+ assert.match(String(undeclared), /line 6\): API has no state count/);
371
+ const uncaptioned = parseServicesSpec(["service API", "step"]);
372
+ assert.match(String(uncaptioned), /line 5\): Give the step a caption/);
373
+ const orphan = parseServicesSpec(["service API", "> Not indented."]);
374
+ assert.match(String(orphan), /line 5\): Put a > line directly below/);
375
+ });
376
+
163
377
  test("parseViewerDocument fails on broken mermaid with file and diagram", () => {
164
378
  const source = brokenMermaidSpec();
165
379
  const parsed = parseViewerDocument(source, "broken.md");
@@ -8,19 +8,28 @@ import {
8
8
  type SourceReference,
9
9
  } from "./annotations.js";
10
10
  import { isRev, type Rev } from "./changes.js";
11
- import { DiffmapAnnotationError, DiffmapParseError } from "./errors.js";
11
+ import { notesProblem } from "./codeNotes.js";
12
+ import {
13
+ DiffmapAnnotationError,
14
+ DiffmapParseError,
15
+ DiffmapServicesError,
16
+ } from "./errors.js";
12
17
  import { parseMermaidDiagram } from "./mermaid.js";
13
18
  import { parseFence, type Fence } from "./parseFence.js";
19
+ import { serviceAnnotations } from "./services.js";
14
20
 
15
21
  export type ViewerDocument = {
16
22
  nodes: ViewerNode[];
17
23
  headings: ViewerHeading[];
18
24
  /** Every reference in stacks, diagrams, and prose. */
19
25
  references: SourceReference[];
20
- /** Revs of `changes` fences, in document order. */
21
- changeRevs: Rev[];
26
+ /** `changes` fences, in document order. */
27
+ changeCards: ChangeCard[];
22
28
  };
23
29
 
30
+ /** A `changes` fence's rev and the paths it lists to review first. */
31
+ export type ChangeCard = { rev: Rev; review: string[] };
32
+
24
33
  export type ViewerHeading = {
25
34
  level: 1 | 2 | 3 | 4 | 5 | 6;
26
35
  text: string;
@@ -85,30 +94,69 @@ export function parseViewerDocument(source: string, path = "spec") {
85
94
  if (tree instanceof Error) return tree;
86
95
  const nodes = tree.nodes.flatMap((node) => fromNode(node));
87
96
  const fences = collectFences(nodes);
88
- const changeRevs: Rev[] = [];
97
+ const changeCards: ChangeCard[] = [];
89
98
  for (const fence of fences) {
90
99
  if (fence.kind === "code" && fence.lang.startsWith("source-diff")) {
91
100
  return new DiffmapAnnotationError({
92
101
  reason: `"${fence.lang}" fences were removed. Commit the change, then use a changes fence with the commit SHA and link lines with [[<sha>:path#L12-L30]]`,
93
102
  });
94
103
  }
104
+ if (fence.kind === "code" || fence.kind === "callstack") {
105
+ const problem = notesProblem({
106
+ // Call stack symbols must appear in the row text readers see, not in a hidden [[reference]].
107
+ lines:
108
+ fence.kind === "code"
109
+ ? fence.source.split("\n")
110
+ : fence.lines.map((line) => line.text),
111
+ highlights: fence.highlights,
112
+ notes: fence.notes,
113
+ });
114
+ if (problem !== undefined)
115
+ return new DiffmapAnnotationError({ reason: problem });
116
+ continue;
117
+ }
95
118
  if (fence.kind !== "changes") continue;
96
119
  if (!isRev(fence.rev)) {
97
120
  return new DiffmapAnnotationError({
98
121
  reason: `Invalid changes "${fence.rev}". Use a commit SHA, a SHA range (a1b2c3d..e4f5a6b), or leave it empty for uncommitted changes`,
99
122
  });
100
123
  }
101
- changeRevs.push(fence.rev);
124
+ const duplicate = fence.review.find(
125
+ (reviewPath, index) => fence.review.indexOf(reviewPath) !== index,
126
+ );
127
+ if (duplicate !== undefined) {
128
+ return new DiffmapAnnotationError({
129
+ reason: `changes "${fence.rev}" lists ${duplicate} twice. List each file to review once`,
130
+ });
131
+ }
132
+ changeCards.push({ rev: fence.rev, review: fence.review });
102
133
  }
103
134
  const annotations = [
104
135
  ...fences.flatMap((fence) => {
105
136
  if (fence.kind === "callstack") return fence.lines;
106
137
  if (fence.kind === "mermaid")
107
138
  return fence.annotations.map((link) => link.annotation);
139
+ if (fence.kind === "services" && fence.parsed.ok)
140
+ return serviceAnnotations(fence.parsed.diagram);
108
141
  return [];
109
142
  }),
110
143
  ...collectReferences(nodes),
111
144
  ];
145
+ const servicesFences = fences.flatMap((fence) =>
146
+ fence.kind === "services" ? [fence] : [],
147
+ );
148
+ for (const [offset, fence] of servicesFences.entries()) {
149
+ if (fence.parsed.ok) continue;
150
+ const start = fenceLine(source, "services", offset + 1);
151
+ return new DiffmapServicesError({
152
+ path,
153
+ where:
154
+ start === undefined
155
+ ? `diagram ${offset + 1}, line ${fence.parsed.line}`
156
+ : `line ${start + fence.parsed.line}`,
157
+ reason: fence.parsed.reason,
158
+ });
159
+ }
112
160
  const mermaidFences = fences.flatMap((fence) =>
113
161
  fence.kind === "mermaid" ? [fence] : [],
114
162
  );
@@ -164,7 +212,7 @@ export function parseViewerDocument(source: string, path = "spec") {
164
212
  nodes,
165
213
  headings: collectHeadings(nodes),
166
214
  references: annotations.flatMap((annotation) => annotation.references),
167
- changeRevs,
215
+ changeCards,
168
216
  };
169
217
  }
170
218
 
@@ -185,15 +233,18 @@ function collectReferences(nodes: ViewerNode[]): SourceAnnotation[] {
185
233
  }
186
234
 
187
235
  function mermaidWhere(source: string, index: number) {
188
- const line = mermaidFenceLine(source, index);
236
+ const line = fenceLine(source, "mermaid", index);
189
237
  if (line === undefined) return `diagram ${index}`;
190
238
  return `diagram ${index}, line ${line}`;
191
239
  }
192
240
 
193
- function mermaidFenceLine(source: string, index: number) {
194
- const match = [...source.matchAll(/^[ \t]*(`{3,}mermaid\b|::mermaid\b)/gm)][
195
- index - 1
196
- ];
241
+ /** Line of the `index`th (1-based) fence or MDC block named `lang`. */
242
+ function fenceLine(source: string, lang: string, index: number) {
243
+ const pattern = new RegExp(
244
+ String.raw`^[ \t]*(\`{3,}${lang}\b|::${lang}\b)`,
245
+ "gm",
246
+ );
247
+ const match = [...source.matchAll(pattern)][index - 1];
197
248
  if (match === undefined || match.index === undefined) return undefined;
198
249
  return source.slice(0, match.index).split("\n").length;
199
250
  }
@@ -323,15 +374,19 @@ function fromElement(
323
374
  if (tag === "template")
324
375
  return children.flatMap((child) => fromNode(child, literalChildren));
325
376
  if (tag === "pre") {
326
- return fenceView(stringAttr(element, "language"), comarkText(children));
377
+ return fenceView(
378
+ stringAttr(element, "language"),
379
+ comarkText(children),
380
+ highlightsAttr(element),
381
+ );
327
382
  }
328
383
  if (tag === "mermaid") return fenceView("mermaid", comarkText(children));
329
384
  if (tag === "callstack") return fenceView("callstack", comarkText(children));
330
- if (tag === "changes")
331
- return fenceView(
332
- "changes",
333
- stringAttr(element, "rev") ?? comarkText(children),
334
- );
385
+ if (tag === "changes") {
386
+ const rev = stringAttr(element, "rev");
387
+ const body = comarkText(children);
388
+ return fenceView("changes", rev === undefined ? body : `${rev}\n${body}`);
389
+ }
335
390
  if (tag === "diff") {
336
391
  const path = stringAttr(element, "path");
337
392
  const lang = path === undefined ? "diff" : `diff:${path}`;
@@ -366,10 +421,14 @@ function unwrapTaskParagraph(children: ViewerNode[]): ViewerNode[] {
366
421
  return children;
367
422
  }
368
423
 
369
- function fenceView(lang: string | undefined, source: string): ViewerView {
424
+ function fenceView(
425
+ lang: string | undefined,
426
+ source: string,
427
+ highlights?: number[],
428
+ ): ViewerView {
370
429
  return {
371
430
  type: "view",
372
- fence: parseFence(lang === undefined ? "" : lang, source),
431
+ fence: parseFence(lang === undefined ? "" : lang, source, highlights),
373
432
  };
374
433
  }
375
434
 
@@ -430,6 +489,13 @@ function stringAttr(element: ComarkElement, key: string) {
430
489
  return value as string;
431
490
  }
432
491
 
492
+ /** md4x parses ```ts {2,4-5} into `highlights: [2, 4, 5]`. */
493
+ function highlightsAttr(element: ComarkElement) {
494
+ const value = element[1].highlights;
495
+ if (!Array.isArray(value)) return undefined;
496
+ return value.filter((line): line is number => typeof line === "number");
497
+ }
498
+
433
499
  function lineAttr(element: ComarkElement, key: string) {
434
500
  const value = stringAttr(element, key);
435
501
  if (value === undefined) return undefined;