@shortlink-org/portolan 0.2.2 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/README.md +7 -2
  2. package/cli/portolan.mjs +36 -6
  3. package/package.json +4 -4
  4. package/plugins/README.md +5 -0
  5. package/plugins/extract-python-kafka/README.md +6 -0
  6. package/plugins/extract-python-kafka/extract.py +2 -2
  7. package/plugins/extract-python-kafka/extract_test.py +17 -1
  8. package/plugins/portolan-go.wasm +0 -0
  9. package/plugins/pyplugin/catalog.py +12 -1
  10. package/plugins/pyplugin/kafka.py +74 -3
  11. package/scripts/builtin-plugins.mjs +3 -2
  12. package/scripts/catalog-sources.mjs +2 -1
  13. package/scripts/catalog-sources.test.mjs +11 -1
  14. package/scripts/delivery-presets.mjs +207 -24
  15. package/scripts/diff.mjs +5 -1
  16. package/scripts/gen-likec4.mjs +78 -20
  17. package/scripts/gen-likec4.test.mjs +25 -2
  18. package/scripts/local-api.mjs +26 -377
  19. package/scripts/local-api.test.mjs +50 -0
  20. package/scripts/local-discovery.mjs +378 -0
  21. package/scripts/manifest.mjs +42 -1
  22. package/scripts/manifest.test.mjs +24 -1
  23. package/scripts/run-builtin.mjs +4 -2
  24. package/scripts/schema.mjs +4 -0
  25. package/scripts/site-docs.mjs +2 -2
  26. package/src/app/Breadcrumbs.test.ts +4 -0
  27. package/src/app/Breadcrumbs.tsx +1 -0
  28. package/src/app/CatalogApp.tsx +4 -4
  29. package/src/app/Sidebar.tsx +13 -730
  30. package/src/app/SidebarFlowSections.tsx +251 -0
  31. package/src/app/SidebarFooter.tsx +160 -0
  32. package/src/app/SidebarTree.tsx +322 -0
  33. package/src/catalog-index.ts +485 -0
  34. package/src/catalog-model.ts +1360 -0
  35. package/src/catalog-stores.test.ts +17 -0
  36. package/src/catalog-validation.ts +1611 -0
  37. package/src/catalog.test.ts +29 -2
  38. package/src/catalog.ts +6 -3306
  39. package/src/components/ChannelRows.messagepack.test.tsx +28 -0
  40. package/src/components/ChannelRows.test.tsx +54 -0
  41. package/src/components/ChannelRows.tsx +57 -10
  42. package/src/components/LifecycleDiagram.tsx +28 -12
  43. package/src/components/{PageHeader.test.ts → PageHeader.test.tsx} +9 -10
  44. package/src/components/ProblemRow.tsx +7 -0
  45. package/src/components/SourcePreview.tsx +1 -1
  46. package/src/components/WhatLinksHere.tsx +6 -4
  47. package/src/enrich.test.ts +4 -5
  48. package/src/flow/StepDetail.tsx +98 -54
  49. package/src/flow/answers.test.ts +18 -1
  50. package/src/flow/answers.ts +37 -8
  51. package/src/index.css +0 -17
  52. package/src/landing/LandingPage.tsx +4 -4
  53. package/src/lib/all-problems.ts +1 -1
  54. package/src/lib/backlinks.test.ts +16 -1
  55. package/src/lib/backlinks.ts +20 -0
  56. package/src/lib/catalog-diff.test.ts +18 -0
  57. package/src/lib/catalog-diff.ts +19 -1
  58. package/src/lib/derive.ts +2 -0
  59. package/src/lib/kafka-ui.test.ts +87 -0
  60. package/src/lib/kafka-ui.ts +105 -0
  61. package/src/lib/local-api.ts +19 -7
  62. package/src/lib/motion.test.ts +4 -2
  63. package/src/lib/motion.tsx +5 -4
  64. package/src/lib/proto-problems.test.ts +170 -3
  65. package/src/lib/proto-problems.ts +176 -4
  66. package/src/lib/wire-problems.test.ts +21 -0
  67. package/src/lib/wire-problems.ts +62 -1
  68. package/src/likec4/FlowView.tsx +2 -6
  69. package/src/likec4/flow-edges.test.ts +64 -1
  70. package/src/likec4/flow-edges.ts +43 -7
  71. package/src/likec4/view-index.ts +8 -2
  72. package/src/merge.test.ts +69 -0
  73. package/src/merge.ts +52 -2
  74. package/src/pages/CatalogFailure.tsx +2 -2
  75. package/src/pages/Settings.tsx +12 -129
  76. package/src/pages/settings/AboutSettings.tsx +129 -0
  77. package/src/pages/settings/DeliverySettings.tsx +71 -15
  78. package/src/pages/settings/IntegrationsSettings.tsx +117 -0
  79. package/src/routes.test.ts +2 -0
  80. package/src/routes.ts +2 -1
  81. package/src/selection/DetailPanel.tsx +46 -1
  82. package/src/selection/pages.test.ts +14 -1
  83. package/src/selection/pages.ts +9 -3
  84. package/vite.config.ts +3 -1
@@ -22,6 +22,26 @@ const GENERATED = "# Generated by Portolan. Preview changes in Settings before u
22
22
  const GITLAB_START = "# >>> Portolan delivery preset >>>";
23
23
  const GITLAB_END = "# <<< Portolan delivery preset <<<";
24
24
  const PROVIDERS = new Set(["github", "gitlab"]);
25
+ const FEATURE_IDS = ["check", "diff", "sarif", "pages"];
26
+ const DEFAULT_FEATURES = ["check", "pages"];
27
+ const FEATURE_DETAILS = {
28
+ check: {
29
+ label: "Architecture check",
30
+ description: "Fail when committed architecture files are stale.",
31
+ },
32
+ diff: {
33
+ label: "Pull-request architecture diff",
34
+ description: "Post a sticky architecture summary where review happens.",
35
+ },
36
+ sarif: {
37
+ label: "SARIF annotations",
38
+ description: "Annotate changed architecture in GitHub's checks and code-scanning views.",
39
+ },
40
+ pages: {
41
+ label: "Static catalog publishing",
42
+ description: "Build and publish the catalog after changes reach the default branch.",
43
+ },
44
+ };
25
45
 
26
46
  function exists(path) {
27
47
  try { lstatSync(path); return true; } catch { return false; }
@@ -153,9 +173,58 @@ jobs:
153
173
  `;
154
174
  }
155
175
 
156
- function gitlabBlock() {
157
- return `${GITLAB_START}
158
- "portolan:check":
176
+ function githubDiff({ pages, sarif }) {
177
+ const site = pages
178
+ ? "\n site: https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}"
179
+ : "";
180
+ const sarifInput = sarif ? "\n sarif-output: .portolan/diff.sarif" : "";
181
+ const sarifPermission = sarif ? "\n security-events: write # annotations in the checks tab" : "";
182
+ const upload = sarif
183
+ ? `
184
+ - name: Annotate the checks tab
185
+ uses: github/codeql-action/upload-sarif@v4
186
+ with:
187
+ sarif_file: .portolan/diff.sarif
188
+ category: architecture-diff
189
+ # Fork pull requests have a read-only token; the Markdown summary still works.
190
+ continue-on-error: true
191
+ `
192
+ : "";
193
+ return `${GENERATED}
194
+ name: Portolan architecture diff
195
+
196
+ on:
197
+ pull_request:
198
+
199
+ permissions:
200
+ contents: read
201
+ pull-requests: write${sarifPermission}
202
+
203
+ concurrency:
204
+ group: portolan-diff-\${{ github.ref }}
205
+ cancel-in-progress: true
206
+
207
+ jobs:
208
+ describe:
209
+ runs-on: ubuntu-latest
210
+ steps:
211
+ - uses: actions/checkout@v7
212
+ with:
213
+ fetch-depth: 0
214
+ - uses: shortlink-org/portolan@${VERSION}
215
+ env:
216
+ GITHUB_TOKEN: \${{ secrets.GITHUB_TOKEN }}
217
+ with:
218
+ command: diff
219
+ version: ${VERSION}
220
+ base-ref: origin/\${{ github.base_ref }}
221
+ head-ref: \${{ github.head_ref }}
222
+ comment: true${site}${sarifInput}
223
+ ${upload}`;
224
+ }
225
+
226
+ function gitlabCheck() {
227
+ return `"portolan:check":
159
228
  stage: .pre
160
229
  image:
161
230
  name: ghcr.io/shortlink-org/portolan:${VERSION}
@@ -167,8 +236,30 @@ function gitlabBlock() {
167
236
  rules:
168
237
  - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
169
238
  - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
239
+ `;
240
+ }
170
241
 
171
- "portolan:pages":
242
+ function gitlabDiff({ pages }) {
243
+ const site = pages ? ' --site "$CI_PAGES_URL"' : "";
244
+ return `"portolan:review":
245
+ stage: .pre
246
+ image:
247
+ name: ghcr.io/shortlink-org/portolan:${VERSION}
248
+ entrypoint: [""]
249
+ variables:
250
+ GIT_DEPTH: "0"
251
+ script:
252
+ - portolan diff "origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME" --head "$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME"${site} --output .portolan/diff.md
253
+ - cat .portolan/diff.md
254
+ - portolan comment .portolan/diff.md
255
+ allow_failure: true
256
+ rules:
257
+ - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
258
+ `;
259
+ }
260
+
261
+ function gitlabPages() {
262
+ return `"portolan:pages":
172
263
  stage: .post
173
264
  image:
174
265
  name: ghcr.io/shortlink-org/portolan:${VERSION}
@@ -182,11 +273,19 @@ function gitlabBlock() {
182
273
  publish: dist
183
274
  rules:
184
275
  - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
185
- ${GITLAB_END}
186
276
  `;
187
277
  }
188
278
 
189
- function mergeGitlab(existing) {
279
+ function gitlabBlock(features) {
280
+ const jobs = [];
281
+ if (features.has("check")) jobs.push(gitlabCheck());
282
+ if (features.has("diff")) jobs.push(gitlabDiff({ pages: features.has("pages") }));
283
+ if (features.has("pages")) jobs.push(gitlabPages());
284
+ if (jobs.length === 0) return "";
285
+ return `${GITLAB_START}\n${jobs.join("\n")}${GITLAB_END}\n`;
286
+ }
287
+
288
+ function mergeGitlab(existing, features) {
190
289
  const start = existing.indexOf(GITLAB_START);
191
290
  const end = existing.indexOf(GITLAB_END);
192
291
  if ((start === -1) !== (end === -1) || (start !== -1 && end < start)) {
@@ -194,12 +293,17 @@ function mergeGitlab(existing) {
194
293
  }
195
294
  if (start !== -1) {
196
295
  const after = end + GITLAB_END.length;
197
- return { content: `${existing.slice(0, start)}${gitlabBlock().trimEnd()}${existing.slice(after)}`.replace(/\n*$/, "\n") };
296
+ const replacement = gitlabBlock(features).trimEnd();
297
+ const content = `${existing.slice(0, start)}${replacement}${existing.slice(after)}`
298
+ .replace(/\n{3,}/g, "\n\n")
299
+ .replace(/^\n+|\n+$/g, "");
300
+ return { content: content ? `${content}\n` : "" };
198
301
  }
199
- if (/^[ \t]*["']?portolan:(?:check|pages)["']?\s*:/m.test(existing)) {
302
+ if (/^[ \t]*["']?portolan:(?:check|review|pages)["']?\s*:/m.test(existing)) {
200
303
  return { conflict: "This pipeline already declares a Portolan job outside the managed region." };
201
304
  }
202
- return { content: `${existing.trimEnd()}${existing.trim() ? "\n\n" : ""}${gitlabBlock()}` };
305
+ const block = gitlabBlock(features);
306
+ return { content: block ? `${existing.trimEnd()}${existing.trim() ? "\n\n" : ""}${block}` : existing };
203
307
  }
204
308
 
205
309
  function textDiff(path, before, after) {
@@ -251,6 +355,16 @@ function readTarget(workspace, path) {
251
355
  function githubFile(workspace, path, content) {
252
356
  const current = readTarget(workspace, path);
253
357
  const managed = current.safe && current.content.startsWith(GENERATED);
358
+ if (content === null) {
359
+ if (!current.exists || !managed) return null;
360
+ return {
361
+ path,
362
+ status: "removed",
363
+ diff: textDiff(path, current.content, ""),
364
+ current: current.content,
365
+ next: "",
366
+ };
367
+ }
254
368
  const status = !current.exists ? "added" : !current.safe || !managed ? "conflict" : current.content === content ? "unchanged" : "changed";
255
369
  return {
256
370
  path,
@@ -262,39 +376,102 @@ function githubFile(workspace, path, content) {
262
376
  };
263
377
  }
264
378
 
265
- function gitlabFile(workspace) {
379
+ function gitlabFile(workspace, features) {
266
380
  const path = ".gitlab-ci.yml";
267
381
  const current = readTarget(workspace, path);
268
382
  if (!current.safe) return { path, status: "conflict", message: "The existing pipeline is not a safe UTF-8 file.", diff: "", current: "", next: "" };
269
- const merged = mergeGitlab(current.content);
383
+ const merged = mergeGitlab(current.content, features);
270
384
  if (merged.conflict) return { path, status: "conflict", message: merged.conflict, diff: "", current: current.content, next: "" };
271
- const status = !current.exists ? "added" : current.content === merged.content ? "unchanged" : "changed";
385
+ const status = current.content === merged.content ? "unchanged" : !current.exists ? "added" : "changed";
272
386
  return { path, status, diff: textDiff(path, current.content, merged.content), current: current.content, next: merged.content };
273
387
  }
274
388
 
275
- function revisionFor(provider, files) {
389
+ function revisionFor(provider, features, files) {
276
390
  const hash = createHash("sha256").update(provider);
391
+ hash.update("\0"); hash.update([...features].sort().join(","));
277
392
  for (const file of files) {
278
393
  hash.update("\0"); hash.update(file.path); hash.update("\0"); hash.update(file.current); hash.update("\0"); hash.update(file.next);
279
394
  }
280
395
  return hash.digest("hex");
281
396
  }
282
397
 
283
- export function planDeliveryPreset(workspace, requestedProvider) {
398
+ function deliveryRequest(request) {
399
+ return typeof request === "string" || request == null ? { provider: request ?? undefined } : request;
400
+ }
401
+
402
+ function installedFeatures(workspace, provider) {
403
+ const selected = new Set();
404
+ if (provider === "github") {
405
+ const check = readTarget(workspace, ".github/workflows/portolan-check.yml");
406
+ const review = readTarget(workspace, ".github/workflows/portolan-review.yml");
407
+ const pages = readTarget(workspace, ".github/workflows/portolan-pages.yml");
408
+ if (check.safe && check.content.startsWith(GENERATED)) selected.add("check");
409
+ if (review.safe && review.content.startsWith(GENERATED)) {
410
+ selected.add("diff");
411
+ if (review.content.includes("sarif-output:")) selected.add("sarif");
412
+ }
413
+ if (pages.safe && pages.content.startsWith(GENERATED)) selected.add("pages");
414
+ return selected;
415
+ }
416
+ const pipeline = readTarget(workspace, ".gitlab-ci.yml");
417
+ if (!pipeline.safe || !pipeline.content.includes(GITLAB_START)) return selected;
418
+ const managed = pipeline.content.slice(
419
+ pipeline.content.indexOf(GITLAB_START),
420
+ pipeline.content.indexOf(GITLAB_END) + GITLAB_END.length,
421
+ );
422
+ if (/^[ \t]*["']?portolan:check["']?\s*:/m.test(managed)) selected.add("check");
423
+ if (/^[ \t]*["']?portolan:review["']?\s*:/m.test(managed)) selected.add("diff");
424
+ if (/^[ \t]*["']?portolan:pages["']?\s*:/m.test(managed)) selected.add("pages");
425
+ return selected;
426
+ }
427
+
428
+ function selectedFeatures(workspace, request, provider) {
429
+ const installed = request.features == null ? installedFeatures(workspace, provider) : null;
430
+ const raw = request.features == null
431
+ ? installed.size > 0 ? [...installed] : DEFAULT_FEATURES
432
+ : Array.isArray(request.features)
433
+ ? request.features
434
+ : String(request.features).split(",").filter(Boolean);
435
+ const selected = new Set(raw);
436
+ for (const feature of selected) {
437
+ if (!FEATURE_IDS.includes(feature)) throw new Error(`Unknown delivery feature ${feature}.`);
438
+ }
439
+ if (selected.has("sarif")) selected.add("diff");
440
+ if (provider !== "github" && selected.has("sarif")) {
441
+ throw new Error("SARIF annotations are currently available only for GitHub.");
442
+ }
443
+ return selected;
444
+ }
445
+
446
+ function featureOptions(provider, selected) {
447
+ return FEATURE_IDS.map((id) => ({
448
+ id,
449
+ ...FEATURE_DETAILS[id],
450
+ selected: selected.has(id),
451
+ available: id !== "sarif" || provider === "github",
452
+ requires: id === "sarif" ? ["diff"] : [],
453
+ }));
454
+ }
455
+
456
+ export function planDeliveryPreset(workspace, requested = {}) {
457
+ const request = deliveryRequest(requested);
284
458
  const detected = detectDeliveryForge(workspace);
285
- const provider = requestedProvider || detected.provider || "github";
459
+ const provider = request.provider || detected.provider || "github";
286
460
  if (!PROVIDERS.has(provider)) throw new Error("Delivery preset must be github or gitlab.");
461
+ const features = selectedFeatures(workspace, request, provider);
287
462
  const files = provider === "github"
288
463
  ? [
289
- githubFile(workspace, ".github/workflows/portolan-check.yml", githubCheck(detected.defaultBranch)),
290
- githubFile(workspace, ".github/workflows/portolan-pages.yml", githubPages(detected.defaultBranch)),
291
- ]
292
- : [gitlabFile(workspace)];
464
+ githubFile(workspace, ".github/workflows/portolan-check.yml", features.has("check") ? githubCheck(detected.defaultBranch) : null),
465
+ githubFile(workspace, ".github/workflows/portolan-review.yml", features.has("diff") ? githubDiff({ pages: features.has("pages"), sarif: features.has("sarif") }) : null),
466
+ githubFile(workspace, ".github/workflows/portolan-pages.yml", features.has("pages") ? githubPages(detected.defaultBranch) : null),
467
+ ].filter(Boolean)
468
+ : [gitlabFile(workspace, features)];
293
469
  const status = files.some((file) => file.status === "conflict")
294
470
  ? "conflict"
295
471
  : files.every((file) => file.status === "unchanged")
296
472
  ? "installed"
297
- : files.some((file) => file.status === "changed") && (provider !== "gitlab" || files[0].current.includes(GITLAB_START))
473
+ : files.some((file) => ["removed", "unchanged"].includes(file.status))
474
+ || files.some((file) => file.status === "changed" && (provider !== "gitlab" || file.current.includes(GITLAB_START)))
298
475
  ? "update"
299
476
  : "available";
300
477
  return {
@@ -304,8 +481,8 @@ export function planDeliveryPreset(workspace, requestedProvider) {
304
481
  repository: detected.repository,
305
482
  defaultBranch: detected.defaultBranch,
306
483
  status,
307
- revision: revisionFor(provider, files),
308
- features: ["Validate generated architecture in reviews", "Publish the static catalog"],
484
+ revision: revisionFor(provider, features, files),
485
+ features: featureOptions(provider, features),
309
486
  files,
310
487
  };
311
488
  }
@@ -315,7 +492,8 @@ export function publicDeliveryPreset(plan) {
315
492
  }
316
493
 
317
494
  export function installDeliveryPreset(workspace, request) {
318
- const plan = planDeliveryPreset(workspace, request.provider);
495
+ const selection = { provider: request.provider, features: request.features };
496
+ const plan = planDeliveryPreset(workspace, selection);
319
497
  if (!request.revision || request.revision !== plan.revision) {
320
498
  const error = new Error("Files changed after this preset preview. Preview it again.");
321
499
  error.status = 409;
@@ -339,6 +517,11 @@ export function installDeliveryPreset(workspace, request) {
339
517
  for (const file of plan.files) {
340
518
  if (file.status === "unchanged") continue;
341
519
  const target = resolve(workspace, file.path);
520
+ if (file.status === "removed") {
521
+ rmSync(target);
522
+ written.push(file.path);
523
+ continue;
524
+ }
342
525
  mkdirSync(dirname(target), { recursive: true });
343
526
  const temporary = join(dirname(target), `.portolan-${randomUUID()}.tmp`);
344
527
  try {
@@ -349,5 +532,5 @@ export function installDeliveryPreset(workspace, request) {
349
532
  }
350
533
  written.push(file.path);
351
534
  }
352
- return { ...publicDeliveryPreset(planDeliveryPreset(workspace, request.provider)), written };
535
+ return { ...publicDeliveryPreset(planDeliveryPreset(workspace, selection)), written };
353
536
  }
package/scripts/diff.mjs CHANGED
@@ -29,6 +29,7 @@ import { enrichCatalog } from "../src/enrich.ts";
29
29
  import { mergeCatalogs } from "../src/merge.ts";
30
30
  import { diffCatalogs, SEVERITIES } from "../src/lib/catalog-diff.ts";
31
31
  import { loadCatalog } from "./catalog-sources.mjs";
32
+ import { readManifestText } from "./manifest.mjs";
32
33
 
33
34
  const HEADINGS = {
34
35
  breaking: "Breaking",
@@ -142,7 +143,10 @@ function defaultBase() {
142
143
 
143
144
  /** The merged, enriched, validated catalog as it stood at `ref`. */
144
145
  function catalogAt(ref) {
145
- const manifest = JSON.parse(git(["show", ref + ":portolan.json"]));
146
+ const manifest = readManifestText(
147
+ git(["show", ref + ":portolan.json"]),
148
+ `${ref}:portolan.json`,
149
+ );
146
150
  const patterns = (manifest.sources ?? []).map(globToRegExp);
147
151
 
148
152
  const paths = git(["ls-tree", "-r", "--name-only", ref])
@@ -590,10 +590,11 @@ function containerPredicates(pairs, carried, indent) {
590
590
  * in a `break` frame, which is what a sequence diagram calls a branch that
591
591
  * leaves the flow rather than rejoining it.
592
592
  */
593
- // --- what comes back from a call (mirrors src/flow/answers.ts) -------------
594
- // Standalone calls keep the contract answer on the request label. Composition
595
- // turns a proven synchronous return into an explicit response step, so the
596
- // request then keeps only its own label and the return gets a dashed arrow.
593
+ // --- request and response edges (mirrors src/flow/answers.ts) --------------
594
+ // A unary RPC is two messages, not one long edge label. When composition has
595
+ // already materialised a response step, that step is the return. Otherwise we
596
+ // draw the contract response: immediately for a nested call, and at the end of
597
+ // the flow for the actor request that opened it.
597
598
  const methodOf = new Map();
598
599
  const serviceById = new Map();
599
600
  for (const context of catalog.contexts) {
@@ -615,29 +616,43 @@ for (const external of catalog.externals ?? []) {
615
616
  }
616
617
  }
617
618
 
618
- function answerOf(step) {
619
- if (step.kind !== "rpc") return "";
620
- if (step.ref) return methodOf.get(step.ref)?.response ?? "";
619
+ function contractOf(step) {
620
+ if (step.kind !== "rpc") return null;
621
+ if (step.ref) return methodOf.get(step.ref) ?? null;
621
622
  const service = serviceById.get(step.to);
622
- if (!service || !step.label) return "";
623
+ if (!service || !step.label) return null;
623
624
  for (const provided of service.provides) {
624
625
  const found = provided.methods.find((m) => m.name === step.label);
625
- if (found) return found.response ?? "";
626
+ if (found) return found;
626
627
  }
627
- return "";
628
+ return null;
629
+ }
630
+
631
+ function emitSyntheticResponse(out, indent, node, response) {
632
+ out.push(
633
+ `${indent}${participantRef(node.to)} -> ${participantRef(node.from)} ${q(response)} {`,
634
+ );
635
+ out.push(
636
+ `${indent} color ${node.status} line dashed head ${KIND_HEAD.response}`,
637
+ );
638
+ out.push(`${indent}}`);
628
639
  }
629
640
 
630
- function emitSteps(nodes, out, indent, replied) {
641
+ function emitSteps(nodes, out, indent, replied, deferredResponseId) {
631
642
  for (const node of nodes) {
632
643
  if (node.type === "step") {
633
- const answer = replied.has(node.id) ? "" : answerOf(node);
644
+ const contract = contractOf(node);
645
+ const request = contract?.request ?? "";
646
+ const response = replied.has(node.id) ? "" : (contract?.response ?? "");
634
647
  const storeLabel =
635
648
  node.storeAccess?.operation && node.storeAccess?.keyspace
636
649
  ? `${node.storeAccess.operation.toUpperCase()} ${node.storeAccess.keyspace}`
637
650
  : "";
638
651
  const label =
639
- (storeLabel || node.label || node.ref || node.kind) +
640
- (answer ? ` ${answer}` : "");
652
+ storeLabel ||
653
+ (node.kind === "rpc" && request
654
+ ? request
655
+ : node.label || node.ref || node.kind);
641
656
  const attrs = [
642
657
  `color ${node.http?.outcome === "error" ? "response_error" : node.status}`,
643
658
  `line ${node.kind === "response" ? "dashed" : "solid"}`,
@@ -656,18 +671,21 @@ function emitSteps(nodes, out, indent, replied) {
656
671
  if (notes.length > 0)
657
672
  out.push(`${indent} notes ${q(notes.join(" — "))}`);
658
673
  out.push(`${indent}}`);
674
+ if (response && node.id !== deferredResponseId) {
675
+ emitSyntheticResponse(out, indent, node, response);
676
+ }
659
677
  continue;
660
678
  }
661
679
  if (node.type === "parallel") {
662
680
  out.push(`${indent}par ${node.title ? `${q(node.title)} ` : ""}{`);
663
681
  for (const branch of node.branches)
664
- emitSteps(branch, out, `${indent} `, replied);
682
+ emitSteps(branch, out, `${indent} `, replied, deferredResponseId);
665
683
  out.push(`${indent}}`);
666
684
  continue;
667
685
  }
668
686
  if (node.type === "loop") {
669
687
  out.push(`${indent}loop ${q(node.title)} {`);
670
- emitSteps(node.steps, out, `${indent} `, replied);
688
+ emitSteps(node.steps, out, `${indent} `, replied, deferredResponseId);
671
689
  out.push(`${indent}}`);
672
690
  continue;
673
691
  }
@@ -678,10 +696,22 @@ function emitSteps(nodes, out, indent, replied) {
678
696
  out.push(`${indent} ${keyword} ${q(branch.title)} {`);
679
697
  if (branch.terminal) {
680
698
  out.push(`${indent} break 'ends the flow' {`);
681
- emitSteps(branch.steps, out, `${indent} `, replied);
699
+ emitSteps(
700
+ branch.steps,
701
+ out,
702
+ `${indent} `,
703
+ replied,
704
+ deferredResponseId,
705
+ );
682
706
  out.push(`${indent} }`);
683
707
  } else {
684
- emitSteps(branch.steps, out, `${indent} `, replied);
708
+ emitSteps(
709
+ branch.steps,
710
+ out,
711
+ `${indent} `,
712
+ replied,
713
+ deferredResponseId,
714
+ );
685
715
  }
686
716
  out.push(`${indent} }`);
687
717
  });
@@ -961,16 +991,34 @@ views.push("");
961
991
  for (const flow of catalog.flows) {
962
992
  const contexts = new Map(flow.participants.map((p) => [p.id, p.context]));
963
993
  const contextOf = (id) => contexts.get(id) ?? null;
994
+ const actorIds = new Set(
995
+ flow.participants.filter((p) => p.kind === "actor").map((p) => p.id),
996
+ );
964
997
  const replied = new Set();
965
998
  walkFlowSteps(flow.steps, (step) => {
966
999
  if (step.kind === "response" && step.replyTo) replied.add(step.replyTo);
967
1000
  });
1001
+ let deferredResponse = null;
1002
+ walkFlowSteps(flow.steps, (step) => {
1003
+ if (deferredResponse || step.kind !== "rpc" || !actorIds.has(step.from))
1004
+ return;
1005
+ const response = replied.has(step.id) ? "" : (contractOf(step)?.response ?? "");
1006
+ if (response) deferredResponse = { step, response };
1007
+ });
968
1008
 
969
1009
  views.push(` dynamic view ${flowViewId(flow)} {`);
970
1010
  views.push(` title ${q(flow.name)}`);
971
1011
  views.push(` description ${q(flow.summary)}`);
972
1012
  const body = [];
973
- emitSteps(flow.steps, body, " ", replied);
1013
+ emitSteps(flow.steps, body, " ", replied, deferredResponse?.step.id);
1014
+ if (deferredResponse) {
1015
+ emitSyntheticResponse(
1016
+ body,
1017
+ " ",
1018
+ deferredResponse.step,
1019
+ deferredResponse.response,
1020
+ );
1021
+ }
974
1022
  views.push(...body);
975
1023
  views.push(" }");
976
1024
  views.push("");
@@ -979,7 +1027,17 @@ for (const flow of catalog.flows) {
979
1027
  views.push(` dynamic view ${flowCrossViewId(flow)} {`);
980
1028
  views.push(` title ${q(`${flow.name} — crossings only`)}`);
981
1029
  const crossBody = [];
982
- emitSteps(cross, crossBody, " ", replied);
1030
+ const crossStepIds = new Set();
1031
+ walkFlowSteps(cross, (step) => crossStepIds.add(step.id));
1032
+ emitSteps(cross, crossBody, " ", replied, deferredResponse?.step.id);
1033
+ if (deferredResponse && crossStepIds.has(deferredResponse.step.id)) {
1034
+ emitSyntheticResponse(
1035
+ crossBody,
1036
+ " ",
1037
+ deferredResponse.step,
1038
+ deferredResponse.response,
1039
+ );
1040
+ }
983
1041
  if (crossBody.length === 0) {
984
1042
  // A flow with no crossing at all still needs a renderable view.
985
1043
  const first = flow.participants[0];
@@ -164,11 +164,34 @@ describe("the LikeC4 generator", () => {
164
164
  },
165
165
  ],
166
166
  },
167
+ {
168
+ id: "flow.get-book-contract",
169
+ slug: "get-book-contract",
170
+ name: "Get book contract",
171
+ summary: "",
172
+ owner: "demo",
173
+ participants: [
174
+ { id: "demo.api", kind: "service", context: "demo" },
175
+ { id: "demo.book", kind: "service", context: "demo" },
176
+ ],
177
+ steps: [
178
+ {
179
+ type: "step",
180
+ id: "request",
181
+ from: "demo.api",
182
+ to: "demo.book",
183
+ kind: "rpc",
184
+ ref: "book.v1.Book/Get",
185
+ label: "Get",
186
+ status: "declared",
187
+ },
188
+ ],
189
+ },
167
190
  ],
168
191
  });
169
192
 
170
193
  expect(views).toContain(
171
- "demo.api -> demo.book 'Get' {\n color declared line solid head normal",
194
+ "demo.api -> demo.book 'GetRequest' {\n color declared line solid head normal",
172
195
  );
173
196
  expect(views).toContain(
174
197
  "demo.book -> demo.api 'GetResponse' {\n color declared line dashed head normal",
@@ -177,7 +200,7 @@ describe("the LikeC4 generator", () => {
177
200
  "demo.book -> demo.api '500 · Error' {\n color response_error line dashed head normal",
178
201
  );
179
202
  expect(spec).toContain("color response_error #b7646b");
180
- expect(views).not.toContain("Get → GetResponse");
203
+ expect(views).not.toContain("GetRequest → GetResponse");
181
204
  });
182
205
 
183
206
  it("treats dots in a root participant id as data, not containment", () => {