@shortlink-org/portolan 0.2.0 → 0.2.2

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 (109) hide show
  1. package/cli/init.mjs +14 -6
  2. package/cli/init.test.mjs +8 -1
  3. package/cli/portolan.mjs +56 -7
  4. package/cli/portolan.test.mjs +47 -0
  5. package/package.json +7 -6
  6. package/plugins/extract-django/README.md +1 -1
  7. package/plugins/extract-django/extract_test.py +1 -1
  8. package/plugins/extract-django/openapi.py +4 -14
  9. package/plugins/extract-django/openapi_test.py +4 -4
  10. package/plugins/extract-go/options.schema.json +5 -0
  11. package/plugins/extract-project/options.schema.json +21 -0
  12. package/plugins/extract-ts/README.md +1 -1
  13. package/plugins/extract-ts/clients.ts +1 -1
  14. package/plugins/extract-ts/extract.test.ts +2 -2
  15. package/plugins/extract-ts/graphql.test.ts +2 -2
  16. package/plugins/extract-ts/openapi.test.ts +6 -7
  17. package/plugins/extract-ts/openapi.ts +3 -10
  18. package/plugins/portolan-go.wasm +0 -0
  19. package/public/404-cat-v1.webp +0 -0
  20. package/public/cat-about-v1.webp +0 -0
  21. package/public/cat-clear-skies-v1.webp +0 -0
  22. package/public/cat-no-changes-v1.webp +0 -0
  23. package/public/cat-onboarding-v1.webp +0 -0
  24. package/public/cat-trial-success-v1.webp +0 -0
  25. package/schema/portolan.schema.json +61 -0
  26. package/scripts/catalog-sources.mjs +12 -1
  27. package/scripts/catalog-sources.test.mjs +35 -0
  28. package/scripts/gen-likec4.mjs +242 -85
  29. package/scripts/gen-likec4.test.mjs +168 -14
  30. package/scripts/gen.mjs +5 -3
  31. package/scripts/host-plugins/fetch-git.mjs +22 -12
  32. package/scripts/host-plugins/fetch-git.test.mjs +25 -5
  33. package/scripts/local-api.mjs +368 -34
  34. package/scripts/local-api.test.mjs +197 -2
  35. package/scripts/package-smoke.mjs +19 -2
  36. package/scripts/plugin-host.mjs +14 -3
  37. package/scripts/plugin-host.test.mjs +8 -0
  38. package/scripts/schema.mjs +7 -0
  39. package/src/app/CatalogApp.tsx +19 -4
  40. package/src/app/CommandPalette.tsx +7 -2
  41. package/src/app/toast.tsx +11 -7
  42. package/src/catalog.test.ts +33 -0
  43. package/src/catalog.ts +190 -8
  44. package/src/components/CatIllustration.tsx +30 -0
  45. package/src/components/CommitLink.tsx +154 -0
  46. package/src/components/EditorLink.tsx +15 -8
  47. package/src/components/GrpcMethodReference.tsx +93 -0
  48. package/src/components/Markdown.tsx +17 -0
  49. package/src/components/MethodRows.tsx +8 -1
  50. package/src/components/PageHeader.test.ts +24 -0
  51. package/src/components/PageHeader.tsx +34 -0
  52. package/src/components/ShapeRows.tsx +12 -0
  53. package/src/components/SourceDoc.tsx +27 -12
  54. package/src/components/SourcePreview.tsx +124 -36
  55. package/src/enrich.test.ts +209 -2
  56. package/src/enrich.ts +284 -15
  57. package/src/er/RedisSchema.tsx +86 -5
  58. package/src/flow/FlowTable.tsx +19 -4
  59. package/src/flow/StepDetail.tsx +461 -101
  60. package/src/flow/StepRail.tsx +36 -13
  61. package/src/flow/answers-response.test.ts +100 -0
  62. package/src/flow/answers.ts +11 -8
  63. package/src/flow/chapters.ts +2 -1
  64. package/src/flow/labels.ts +10 -0
  65. package/src/flow/mermaid.test.ts +87 -3
  66. package/src/flow/mermaid.ts +26 -14
  67. package/src/index.css +132 -0
  68. package/src/landing/LandingPage.tsx +12 -8
  69. package/src/lib/api.test.ts +4 -6
  70. package/src/lib/build-info.test.ts +29 -0
  71. package/src/lib/build-info.ts +21 -0
  72. package/src/lib/data-model.ts +14 -0
  73. package/src/lib/flow-tree.ts +1 -1
  74. package/src/lib/github-catalog.test.ts +51 -0
  75. package/src/lib/github-catalog.ts +69 -0
  76. package/src/lib/grpc-reference.test.ts +72 -0
  77. package/src/lib/grpc-reference.ts +117 -0
  78. package/src/lib/local-api.ts +17 -4
  79. package/src/lib/product.ts +7 -0
  80. package/src/lib/queries.ts +16 -1
  81. package/src/lib/readme-assets.test.ts +19 -0
  82. package/src/lib/readme-assets.ts +34 -0
  83. package/src/lib/setup-info.test.ts +7 -0
  84. package/src/lib/setup-info.ts +5 -0
  85. package/src/lib/source-link.test.ts +106 -18
  86. package/src/lib/source-link.ts +22 -6
  87. package/src/merge.test.ts +55 -0
  88. package/src/merge.ts +30 -0
  89. package/src/pages/AdrDetail.tsx +3 -2
  90. package/src/pages/AdrIndex.tsx +10 -7
  91. package/src/pages/AggregatePage.tsx +34 -4
  92. package/src/pages/BlockPage.tsx +39 -32
  93. package/src/pages/Changes.tsx +7 -9
  94. package/src/pages/ContextMap.tsx +11 -2
  95. package/src/pages/ContextPage.tsx +11 -4
  96. package/src/pages/FlowIndex.tsx +16 -7
  97. package/src/pages/GraphPage.tsx +21 -7
  98. package/src/pages/Language.tsx +9 -9
  99. package/src/pages/NotFound.tsx +58 -15
  100. package/src/pages/Overview.tsx +6 -0
  101. package/src/pages/Problems.tsx +10 -8
  102. package/src/pages/RegistryIndex.tsx +10 -8
  103. package/src/pages/ServicePage.tsx +1 -1
  104. package/src/pages/Settings.tsx +382 -44
  105. package/src/routes.test.ts +2 -0
  106. package/src/routes.ts +2 -1
  107. package/src/selection/DetailPanel.tsx +213 -113
  108. package/src/testing/setup.ts +14 -0
  109. package/vite.config.ts +5 -1
@@ -11,12 +11,13 @@ import { writeFileSync, mkdirSync } from "node:fs";
11
11
 
12
12
  import { loadCatalog } from "./catalog-sources.mjs";
13
13
  import reserved from "../src/likec4/reserved.json" with { type: "json" };
14
+ import { catalogProfiles } from "../src/catalog-profile.ts";
14
15
 
15
16
  // Every source, not one file: a service that publishes its own facts gets a
16
17
  // C4 view like any other, and generating from a single file would leave it out
17
18
  // of the pictures while the rest of the app knows about it.
18
19
  const { catalog, manifest } = await loadCatalog();
19
- const profiles = manifest.catalogs ?? [];
20
+ const profiles = catalogProfiles(manifest);
20
21
 
21
22
  // --- ids (mirrors src/likec4/ids.ts; kept in step by src/likec4/ids.test.ts) ---
22
23
  // The reserved words are not mirrored, they are the same file: a word the
@@ -25,7 +26,9 @@ const profiles = manifest.catalogs ?? [];
25
26
  const RESERVED = new Set(reserved);
26
27
  const safeId = (raw) => {
27
28
  const cleaned = raw.replace(/[^A-Za-z0-9_]/g, "_");
28
- return /^[0-9]/.test(cleaned) || RESERVED.has(cleaned) ? `_${cleaned}` : cleaned;
29
+ return /^[0-9]/.test(cleaned) || RESERVED.has(cleaned)
30
+ ? `_${cleaned}`
31
+ : cleaned;
29
32
  };
30
33
  const fqn = (id) => id.split(".").map(safeId).join(".");
31
34
  const flowViewId = (flow) => `flow_${safeId(flow.slug)}`;
@@ -35,17 +38,24 @@ const serviceViewId = (s) => `svc_${safeId(s.id)}`;
35
38
  const serviceInsideViewId = (s) => `${serviceViewId(s)}_inside`;
36
39
  const LANDSCAPE_VIEW = "landscape";
37
40
  const CONTAINERS_VIEW = "containers";
38
- const profileLandscapeViewId = (profile) => `${LANDSCAPE_VIEW}_${safeId(profile.id)}`;
39
- const profileContainersViewId = (profile) => `${CONTAINERS_VIEW}_${safeId(profile.id)}`;
41
+ const profileLandscapeViewId = (profile) =>
42
+ `${LANDSCAPE_VIEW}_${safeId(profile.id)}`;
43
+ const profileContainersViewId = (profile) =>
44
+ `${CONTAINERS_VIEW}_${safeId(profile.id)}`;
45
+ const includeTargets = (targets) =>
46
+ targets.length > 0 ? targets.join(", ") : "*";
40
47
 
41
- const q = (text) => `'${String(text).replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
48
+ const q = (text) =>
49
+ `'${String(text).replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
42
50
 
43
51
  // The protocol a call travels on, read off the document that declares it.
44
52
  // Mirrors sourceDocKind in src/lib/source-doc.ts, plus the generated stub a
45
53
  // Go client is read from when no proto is vendored. Empty when the source
46
54
  // says nothing a reader could name.
47
55
  const protocolOf = (source) => {
48
- const path = String(source ?? "").replace(/:\d+$/, "").toLowerCase();
56
+ const path = String(source ?? "")
57
+ .replace(/:\d+$/, "")
58
+ .toLowerCase();
49
59
  if (path.endsWith(".proto") || path.endsWith(".pb.go")) return "gRPC";
50
60
  if (path.endsWith(".wsdl")) return "SOAP";
51
61
  if (path.endsWith(".graphql") || path.endsWith(".graphqls")) return "GraphQL";
@@ -58,29 +68,57 @@ const protocolOf = (source) => {
58
68
  // contracts it provides. Both are facts the catalog holds; neither is guessed.
59
69
  const technologyOf = (service) => {
60
70
  const protocols = new Set(
61
- service.provides.map((provided) => protocolOf(provided.source)).filter(Boolean),
71
+ service.provides
72
+ .map((provided) => protocolOf(provided.source))
73
+ .filter(Boolean),
74
+ );
75
+ return [...(service.technologies ?? []), ...[...protocols].sort()].join(
76
+ " · ",
62
77
  );
63
- return [...(service.technologies ?? []), ...[...protocols].sort()].join(" · ");
64
78
  };
65
79
 
66
80
  // --- shared visual language ------------------------------------------------
67
81
  // These hex values are the light/dark-neutral midpoints of the --ctx-N and
68
82
  // --status-* tokens in src/index.css. LikeC4 bakes colours into its own theme,
69
83
  // so the seam between renderers is kept invisible by hand here.
70
- const CTX_COLORS = ["#4a86c8", "#9a66cc", "#2aa196", "#c4703f", "#6b7cb4", "#b46b8b"];
84
+ const CTX_COLORS = [
85
+ "#4a86c8",
86
+ "#9a66cc",
87
+ "#2aa196",
88
+ "#c4703f",
89
+ "#6b7cb4",
90
+ "#b46b8b",
91
+ ];
71
92
  const STATUS_COLORS = {
72
93
  verified: "#2f9e63",
73
94
  declared: "#b8912f",
74
95
  unresolved: "#e0453f",
75
96
  };
76
- const STATUS_LINE = { verified: "solid", declared: "dashed", unresolved: "dotted" };
97
+ const RESPONSE_ERROR_COLOR = "#b7646b";
98
+ const STATUS_LINE = {
99
+ verified: "solid",
100
+ declared: "dashed",
101
+ unresolved: "dotted",
102
+ };
77
103
  const STATUS_RANK = { verified: 0, declared: 1, unresolved: 2 };
78
- const KIND_HEAD = { event: "onormal", rpc: "normal", call: "none" };
104
+ const KIND_HEAD = {
105
+ event: "onormal",
106
+ rpc: "normal",
107
+ call: "none",
108
+ response: "normal",
109
+ };
79
110
 
80
111
  // Every relation carries the kind of fact it is, so a view can say which
81
112
  // facts it draws: a level-2 picture with the bus on it leaves out the arrows
82
113
  // that skip the bus, and nothing else has to change for it to do so.
83
- const RELATION_KINDS = ["consumes", "calls", "bus", "reads", "persists", "uses"];
114
+ const RELATION_KINDS = [
115
+ "consumes",
116
+ "calls",
117
+ "bus",
118
+ "reads",
119
+ "persists",
120
+ "uses",
121
+ ];
84
122
 
85
123
  const contextColorName = (contextId) => {
86
124
  const i = catalog.contexts.findIndex((c) => c.id === contextId);
@@ -102,7 +140,10 @@ for (const flow of catalog.flows) {
102
140
  // not a flow has walked to it yet: a call recorded on `consumes` lands on it,
103
141
  // and the catalog knows what it is called, which a lane's bare id does not.
104
142
  for (const external of catalog.externals ?? []) {
105
- rootParticipants.set(external.id, { kind: "external", label: external.name || external.id });
143
+ rootParticipants.set(external.id, {
144
+ kind: "external",
145
+ label: external.name || external.id,
146
+ });
106
147
  }
107
148
  // Event consumers that no service accounts for are real dependencies too.
108
149
  for (const context of catalog.contexts) {
@@ -128,7 +169,8 @@ for (const context of catalog.contexts) {
128
169
  // one participant, joined by the label the lane kept, so the call resolves to
129
170
  // the lane and not to a `v1` nested inside a `risk` that nobody declared.
130
171
  const participantByLabel = new Map();
131
- for (const [id, meta] of rootParticipants) participantByLabel.set(meta.label, id);
172
+ for (const [id, meta] of rootParticipants)
173
+ participantByLabel.set(meta.label, id);
132
174
  // A dot means containment only for catalog services. Root participants are
133
175
  // declared as one safe identifier, so a broker named `river.orders` must be
134
176
  // referenced as `river_orders`, not as an undeclared `orders` inside `river`.
@@ -156,9 +198,11 @@ for (const store of catalog.stores ?? []) {
156
198
  function walkFlowSteps(nodes, visit) {
157
199
  for (const node of nodes) {
158
200
  if (node.type === "step") visit(node);
159
- else if (node.type === "parallel") node.branches.forEach((b) => walkFlowSteps(b, visit));
201
+ else if (node.type === "parallel")
202
+ node.branches.forEach((b) => walkFlowSteps(b, visit));
160
203
  else if (node.type === "loop") walkFlowSteps(node.steps, visit);
161
- else if (node.type === "alt") node.branches.forEach((b) => walkFlowSteps(b.steps, visit));
204
+ else if (node.type === "alt")
205
+ node.branches.forEach((b) => walkFlowSteps(b.steps, visit));
162
206
  }
163
207
  }
164
208
 
@@ -166,12 +210,15 @@ function walkFlowSteps(nodes, visit) {
166
210
  // specification
167
211
  // ---------------------------------------------------------------------------
168
212
  const spec = [];
169
- spec.push("// GENERATED by scripts/gen-likec4.mjs from the merged catalog — do not edit.");
213
+ spec.push(
214
+ "// GENERATED by scripts/gen-likec4.mjs from the merged catalog — do not edit.",
215
+ );
170
216
  spec.push("specification {");
171
217
  CTX_COLORS.forEach((hex, i) => spec.push(` color ctx${i} ${hex}`));
172
218
  for (const [name, hex] of Object.entries(STATUS_COLORS)) {
173
219
  spec.push(` color ${name} ${hex}`);
174
220
  }
221
+ spec.push(` color response_error ${RESPONSE_ERROR_COLOR}`);
175
222
  spec.push("");
176
223
  spec.push(" element context {");
177
224
  spec.push(" style { shape rectangle opacity 5% border dashed }");
@@ -217,16 +264,22 @@ model.push("");
217
264
 
218
265
  for (const context of catalog.contexts) {
219
266
  model.push(` ${safeId(context.id)} = context ${q(context.name)} {`);
220
- model.push(` description ${q([context.kind, context.summary].filter(Boolean).join(" · "))}`);
267
+ model.push(
268
+ ` description ${q([context.kind, context.summary].filter(Boolean).join(" · "))}`,
269
+ );
221
270
  model.push(` style { color ${contextColorName(context.id)} }`);
222
271
  for (const service of context.services) {
223
272
  model.push(` ${safeId(service.slug)} = service ${q(service.name)} {`);
224
- model.push(` description ${q([service.kind, ...(service.technologies ?? []), `${service.repo}/${service.path}`].filter(Boolean).join(" · "))}`);
273
+ model.push(
274
+ ` description ${q([service.kind, ...(service.technologies ?? []), `${service.repo}/${service.path}`].filter(Boolean).join(" · "))}`,
275
+ );
225
276
  const technology = technologyOf(service);
226
277
  if (technology) model.push(` technology ${q(technology)}`);
227
278
  model.push(` style { color ${contextColorName(context.id)} }`);
228
279
  for (const aggregate of service.aggregates) {
229
- model.push(` ${safeId(aggregate.slug)} = aggregate ${q(aggregate.name)} {`);
280
+ model.push(
281
+ ` ${safeId(aggregate.slug)} = aggregate ${q(aggregate.name)} {`,
282
+ );
230
283
  for (const event of aggregate.events) {
231
284
  const latest = event.versions[event.versions.length - 1];
232
285
  model.push(` ${safeId(event.name)} = event ${q(event.name)} {`);
@@ -296,20 +349,38 @@ for (const context of catalog.contexts) {
296
349
  // and the tables that carry it are the label. A table persisting an aggregate
297
350
  // another service owns draws the same arrow across the boundary, which is the
298
351
  // crossing the Problems page reports.
299
- const persists = new Map(); // "aggregate|store" -> { aggregate, store, tables:[] }
352
+ const persists = new Map(); // "aggregate|store" -> { aggregate, store, labels:[] }
300
353
  for (const store of catalog.stores ?? []) {
301
354
  for (const table of store.tables) {
302
355
  const aggregate = table.persists?.aggregate;
303
356
  if (!aggregate) continue;
304
357
  const key = `${aggregate}|${store.id}`;
305
- const edge = persists.get(key) ?? { aggregate, store: store.id, tables: [] };
306
- edge.tables.push(table.name);
358
+ const edge = persists.get(key) ?? {
359
+ aggregate,
360
+ store: store.id,
361
+ labels: [],
362
+ };
363
+ edge.labels.push(table.name);
364
+ persists.set(key, edge);
365
+ }
366
+ for (const keyspace of store.keyspaces ?? []) {
367
+ const aggregate = keyspace.persists?.aggregate;
368
+ if (!aggregate) continue;
369
+ const key = `${aggregate}|${store.id}`;
370
+ const edge = persists.get(key) ?? {
371
+ aggregate,
372
+ store: store.id,
373
+ labels: [],
374
+ };
375
+ edge.labels.push(keyspace.pattern);
307
376
  persists.set(key, edge);
308
377
  }
309
378
  }
310
379
  for (const edge of persists.values()) {
311
380
  const label =
312
- edge.tables.length > 3 ? `${edge.tables.length} tables` : edge.tables.join(", ");
381
+ edge.labels.length > 3
382
+ ? `${edge.labels.length} persisted shapes`
383
+ : edge.labels.join(", ");
313
384
  relations.push(
314
385
  ` ${fqn(edge.aggregate)} -[persists]-> ${fqn(edge.store)} ${q(label)} {\n` +
315
386
  ` style { color declared line ${STATUS_LINE.declared} head normal }\n` +
@@ -324,11 +395,14 @@ for (const edge of persists.values()) {
324
395
  // however many events or jobs travel that way — so a level-2 picture can put
325
396
  // the bus between publisher and subscriber instead of an arrow that skips it.
326
397
  const brokerIds = new Set(
327
- [...rootParticipants].filter(([, meta]) => meta.kind === "broker").map(([id]) => id),
398
+ [...rootParticipants]
399
+ .filter(([, meta]) => meta.kind === "broker")
400
+ .map(([id]) => id),
328
401
  );
329
402
  const busEdges = new Map(); // "from|to" -> { from, to, kind, labels:Set, status }
330
403
  for (const flow of catalog.flows) {
331
404
  walkFlowSteps(flow.steps, (step) => {
405
+ if (step.kind === "response") return;
332
406
  if (brokerIds.has(step.from) === brokerIds.has(step.to)) return;
333
407
  const key = `${step.from}|${step.to}`;
334
408
  const edge = busEdges.get(key) ?? {
@@ -339,7 +413,8 @@ for (const flow of catalog.flows) {
339
413
  status: "unresolved",
340
414
  };
341
415
  if (step.label) edge.labels.add(step.label);
342
- if (STATUS_RANK[step.status] < STATUS_RANK[edge.status]) edge.status = step.status;
416
+ if (STATUS_RANK[step.status] < STATUS_RANK[edge.status])
417
+ edge.status = step.status;
343
418
  busEdges.set(key, edge);
344
419
  });
345
420
  }
@@ -359,7 +434,9 @@ for (const edge of busEdges.values()) {
359
434
  // the only evidence that they touch the estate at all, so it is read as a
360
435
  // relation — once per pair, however many flows walk it.
361
436
  const actorIds = new Set(
362
- [...rootParticipants].filter(([, meta]) => meta.kind === "actor").map(([id]) => id),
437
+ [...rootParticipants]
438
+ .filter(([, meta]) => meta.kind === "actor")
439
+ .map(([id]) => id),
363
440
  );
364
441
  //
365
442
  // The lane is a fallback, though: an extractor opens every inbound endpoint
@@ -381,6 +458,7 @@ for (const context of catalog.contexts) {
381
458
  const actorEdges = new Map(); // "from|to" -> { from, to, flows:Set, status }
382
459
  for (const flow of catalog.flows) {
383
460
  walkFlowSteps(flow.steps, (step) => {
461
+ if (step.kind === "response") return;
384
462
  if (!actorIds.has(step.from) && !actorIds.has(step.to)) return;
385
463
  if (step.from === step.to) return;
386
464
  const key = `${step.from}|${step.to}`;
@@ -393,7 +471,8 @@ for (const flow of catalog.flows) {
393
471
  edge.flows.add(flow.name);
394
472
  // The best evidence any step offers: one observed crossing is enough to
395
473
  // say the actor really does touch the estate there.
396
- if (STATUS_RANK[step.status] < STATUS_RANK[edge.status]) edge.status = step.status;
474
+ if (STATUS_RANK[step.status] < STATUS_RANK[edge.status])
475
+ edge.status = step.status;
397
476
  actorEdges.set(key, edge);
398
477
  });
399
478
  }
@@ -435,7 +514,8 @@ for (const context of catalog.contexts) {
435
514
  pair.methods.push(call.id.split("/").pop() ?? call.id);
436
515
  const protocol = protocolOf(call.source);
437
516
  if (protocol) pair.protocols.add(protocol);
438
- if (STATUS_RANK[call.status] < STATUS_RANK[pair.status]) pair.status = call.status;
517
+ if (STATUS_RANK[call.status] < STATUS_RANK[pair.status])
518
+ pair.status = call.status;
439
519
  callPairs.set(key, pair);
440
520
  }
441
521
  }
@@ -444,7 +524,9 @@ for (const context of catalog.contexts) {
444
524
  /** The `include a -> b with { … }` line that labels one pair's folded edge. */
445
525
  function pairEdge(pair) {
446
526
  const title =
447
- pair.methods.length === 1 ? pair.methods[0] : `${pair.methods.length} calls`;
527
+ pair.methods.length === 1
528
+ ? pair.methods[0]
529
+ : `${pair.methods.length} calls`;
448
530
  const technology = [...pair.protocols].sort().join(" · ");
449
531
  const props = [
450
532
  `title ${q(title)}`,
@@ -471,7 +553,9 @@ for (const context of catalog.contexts) {
471
553
  consumers.delete(service.id);
472
554
  for (const consumer of consumers) {
473
555
  const via = [...brokerIds].some(
474
- (broker) => busEdges.has(`${service.id}|${broker}`) && busEdges.has(`${broker}|${consumer}`),
556
+ (broker) =>
557
+ busEdges.has(`${service.id}|${broker}`) &&
558
+ busEdges.has(`${broker}|${consumer}`),
475
559
  );
476
560
  if (via) carriedByBus.push([service.id, consumer]);
477
561
  }
@@ -507,10 +591,9 @@ function containerPredicates(pairs, carried, indent) {
507
591
  * leaves the flow rather than rejoining it.
508
592
  */
509
593
  // --- what comes back from a call (mirrors src/flow/answers.ts) -------------
510
- // The catalog records hops and never replies, so a reply is looked up in the
511
- // contract the step reaches. It rides on the step's own label rather than
512
- // becoming an arrow of its own: a dynamic view numbers its steps, and those
513
- // numbers are the ones the rail beside the canvas counts.
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.
514
597
  const methodOf = new Map();
515
598
  const serviceById = new Map();
516
599
  for (const context of catalog.contexts) {
@@ -544,15 +627,20 @@ function answerOf(step) {
544
627
  return "";
545
628
  }
546
629
 
547
- function emitSteps(nodes, out, indent) {
630
+ function emitSteps(nodes, out, indent, replied) {
548
631
  for (const node of nodes) {
549
632
  if (node.type === "step") {
550
- const answer = answerOf(node);
633
+ const answer = replied.has(node.id) ? "" : answerOf(node);
634
+ const storeLabel =
635
+ node.storeAccess?.operation && node.storeAccess?.keyspace
636
+ ? `${node.storeAccess.operation.toUpperCase()} ${node.storeAccess.keyspace}`
637
+ : "";
551
638
  const label =
552
- (node.label ?? node.ref ?? node.kind) + (answer ? ` → ${answer}` : "");
639
+ (storeLabel || node.label || node.ref || node.kind) +
640
+ (answer ? ` → ${answer}` : "");
553
641
  const attrs = [
554
- `color ${node.status}`,
555
- `line ${STATUS_LINE[node.status]}`,
642
+ `color ${node.http?.outcome === "error" ? "response_error" : node.status}`,
643
+ `line ${node.kind === "response" ? "dashed" : "solid"}`,
556
644
  `head ${KIND_HEAD[node.kind]}`,
557
645
  ];
558
646
  // The condition used to be pasted onto every label because there was no
@@ -560,21 +648,26 @@ function emitSteps(nodes, out, indent) {
560
648
  const notes = [];
561
649
  if (node.note) notes.push(node.note);
562
650
  if (node.line) notes.push(node.line);
563
- out.push(`${indent}${participantRef(node.from)} -> ${participantRef(node.to)} ${q(label)} {`);
651
+ if (node.storeAccess?.source) notes.push(node.storeAccess.source);
652
+ out.push(
653
+ `${indent}${participantRef(node.from)} -> ${participantRef(node.to)} ${q(label)} {`,
654
+ );
564
655
  out.push(`${indent} ${attrs.join(" ")}`);
565
- if (notes.length > 0) out.push(`${indent} notes ${q(notes.join(" — "))}`);
656
+ if (notes.length > 0)
657
+ out.push(`${indent} notes ${q(notes.join(" — "))}`);
566
658
  out.push(`${indent}}`);
567
659
  continue;
568
660
  }
569
661
  if (node.type === "parallel") {
570
662
  out.push(`${indent}par ${node.title ? `${q(node.title)} ` : ""}{`);
571
- for (const branch of node.branches) emitSteps(branch, out, `${indent} `);
663
+ for (const branch of node.branches)
664
+ emitSteps(branch, out, `${indent} `, replied);
572
665
  out.push(`${indent}}`);
573
666
  continue;
574
667
  }
575
668
  if (node.type === "loop") {
576
669
  out.push(`${indent}loop ${q(node.title)} {`);
577
- emitSteps(node.steps, out, `${indent} `);
670
+ emitSteps(node.steps, out, `${indent} `, replied);
578
671
  out.push(`${indent}}`);
579
672
  continue;
580
673
  }
@@ -585,10 +678,10 @@ function emitSteps(nodes, out, indent) {
585
678
  out.push(`${indent} ${keyword} ${q(branch.title)} {`);
586
679
  if (branch.terminal) {
587
680
  out.push(`${indent} break 'ends the flow' {`);
588
- emitSteps(branch.steps, out, `${indent} `);
681
+ emitSteps(branch.steps, out, `${indent} `, replied);
589
682
  out.push(`${indent} }`);
590
683
  } else {
591
- emitSteps(branch.steps, out, `${indent} `);
684
+ emitSteps(branch.steps, out, `${indent} `, replied);
592
685
  }
593
686
  out.push(`${indent} }`);
594
687
  });
@@ -647,12 +740,14 @@ const outside = [...rootParticipants]
647
740
  views.push(` view ${LANDSCAPE_VIEW} {`);
648
741
  views.push(" title 'Estate'");
649
742
  views.push(
650
- catalog.contexts.some((context) => context.kind && context.kind !== "bounded-context")
743
+ catalog.contexts.some(
744
+ (context) => context.kind && context.kind !== "bounded-context",
745
+ )
651
746
  ? " description 'Every architecture group, and everything outside the estate that touches one.'"
652
747
  : " description 'Every bounded context, and everything outside the estate that touches one.'",
653
748
  );
654
749
  views.push(
655
- ` include ${[...catalog.contexts.map((c) => safeId(c.id)), ...outside].join(", ")}`,
750
+ ` include ${includeTargets([...catalog.contexts.map((c) => safeId(c.id)), ...outside])}`,
656
751
  );
657
752
  views.push(" }");
658
753
  views.push("");
@@ -665,28 +760,36 @@ views.push("");
665
760
  // mean when they ask for "the architecture".
666
761
  const brokersOfService = new Map(); // service id -> Set of broker ids
667
762
  for (const edge of busEdges.values()) {
668
- const [service, broker] = brokerIds.has(edge.to) ? [edge.from, edge.to] : [edge.to, edge.from];
763
+ const [service, broker] = brokerIds.has(edge.to)
764
+ ? [edge.from, edge.to]
765
+ : [edge.to, edge.from];
669
766
  const set = brokersOfService.get(service) ?? new Set();
670
767
  set.add(broker);
671
768
  brokersOfService.set(service, set);
672
769
  }
673
770
  const allServices = catalog.contexts.flatMap((c) => c.services);
674
- const drawnBrokers = [...new Set(allServices.flatMap((s) => [...(brokersOfService.get(s.id) ?? [])]))];
771
+ const drawnBrokers = [
772
+ ...new Set(
773
+ allServices.flatMap((s) => [...(brokersOfService.get(s.id) ?? [])]),
774
+ ),
775
+ ];
675
776
  views.push(` view ${CONTAINERS_VIEW} {`);
676
777
  views.push(" title 'Containers'");
677
778
  views.push(
678
779
  " description 'Every service, the store it keeps its state in, the brokers between them, and everything outside the estate that touches one.'",
679
780
  );
680
781
  views.push(
681
- ` include ${[
782
+ ` include ${includeTargets([
682
783
  ...catalog.contexts.map((c) => safeId(c.id)),
683
784
  ...allServices.map((s) => fqn(s.id)),
684
785
  ...(catalog.stores ?? []).map((store) => fqn(store.id)),
685
786
  ...drawnBrokers.map(safeId),
686
787
  ...outside,
687
- ].join(", ")}`,
788
+ ])}`,
789
+ );
790
+ views.push(
791
+ ...containerPredicates([...callPairs.values()], carriedByBus, " "),
688
792
  );
689
- views.push(...containerPredicates([...callPairs.values()], carriedByBus, " "));
690
793
  views.push(" }");
691
794
  views.push("");
692
795
 
@@ -695,15 +798,30 @@ views.push("");
695
798
  // UI uses: only the configured groups and the outsiders their own flows name
696
799
  // are included.
697
800
  for (const profile of profiles) {
698
- const profileContexts = catalog.contexts.filter((context) => profile.contexts.includes(context.id));
699
- const profileContextIds = new Set(profileContexts.map((context) => context.id));
700
- const profileServices = profileContexts.flatMap((context) => context.services);
701
- const profileServiceIds = new Set(profileServices.map((service) => service.id));
801
+ // An empty context selection is the historical single-catalog manifest's
802
+ // implicit `default` profile. The browser treats it as the whole catalog;
803
+ // the generated views must do the same or it asks for landscape_default
804
+ // while the bundle only contains the unscoped landscape view.
805
+ const profileContexts = profile.contexts.length
806
+ ? catalog.contexts.filter((context) =>
807
+ profile.contexts.includes(context.id),
808
+ )
809
+ : catalog.contexts;
810
+ const profileContextIds = new Set(
811
+ profileContexts.map((context) => context.id),
812
+ );
813
+ const profileServices = profileContexts.flatMap(
814
+ (context) => context.services,
815
+ );
816
+ const profileServiceIds = new Set(
817
+ profileServices.map((service) => service.id),
818
+ );
702
819
  const profileRoots = new Set();
703
820
  for (const flow of catalog.flows) {
704
821
  if (!profileContextIds.has(flow.owner)) continue;
705
822
  for (const participant of flow.participants) {
706
- if (!profileServiceIds.has(participant.id)) profileRoots.add(participant.id);
823
+ if (!profileServiceIds.has(participant.id))
824
+ profileRoots.add(participant.id);
707
825
  }
708
826
  }
709
827
  for (const service of profileServices) {
@@ -714,7 +832,8 @@ for (const profile of profiles) {
714
832
  for (const aggregate of service.aggregates) {
715
833
  for (const event of aggregate.events) {
716
834
  for (const consumer of event.consumers) {
717
- if (!profileServiceIds.has(consumer.service)) profileRoots.add(consumer.service);
835
+ if (!profileServiceIds.has(consumer.service))
836
+ profileRoots.add(consumer.service);
718
837
  }
719
838
  }
720
839
  }
@@ -725,30 +844,48 @@ for (const profile of profiles) {
725
844
  const profileBrokers = [...profileRoots]
726
845
  .filter((id) => rootParticipants.get(id)?.kind === "broker")
727
846
  .map(safeId);
728
- const profileStores = (catalog.stores ?? []).filter((store) => profileServiceIds.has(store.owner));
847
+ const profileStores = (catalog.stores ?? []).filter((store) =>
848
+ profileServiceIds.has(store.owner),
849
+ );
729
850
 
730
851
  views.push(` view ${profileLandscapeViewId(profile)} {`);
731
852
  views.push(` title ${q(profile.title)}`);
732
- views.push(` description 'The groups and outside systems selected by this catalog profile.'`);
733
- views.push(` include ${[...profileContexts.map((context) => safeId(context.id)), ...profileOutside].join(", ")}`);
853
+ views.push(
854
+ ` description 'The groups and outside systems selected by this catalog profile.'`,
855
+ );
856
+ views.push(
857
+ ` include ${includeTargets([...profileContexts.map((context) => safeId(context.id)), ...profileOutside])}`,
858
+ );
734
859
  views.push(" }");
735
860
  views.push("");
736
861
 
737
862
  views.push(` view ${profileContainersViewId(profile)} {`);
738
863
  views.push(` title ${q(`${profile.title} containers`)}`);
739
- views.push(` description 'The services, stores and transports selected by this catalog profile.'`);
740
- views.push(` include ${[
741
- ...profileContexts.map((context) => safeId(context.id)),
742
- ...profileServices.map((service) => fqn(service.id)),
743
- ...profileStores.map((store) => fqn(store.id)),
744
- ...profileBrokers,
745
- ...profileOutside,
746
- ].join(", ")}`);
747
- views.push(...containerPredicates(
748
- [...callPairs.values()].filter((pair) => profileServiceIds.has(pair.from) && profileServiceIds.has(pair.to)),
749
- carriedByBus.filter((pair) => profileServiceIds.has(pair.from) && profileServiceIds.has(pair.to)),
750
- " ",
751
- ));
864
+ views.push(
865
+ ` description 'The services, stores and transports selected by this catalog profile.'`,
866
+ );
867
+ views.push(
868
+ ` include ${includeTargets([
869
+ ...profileContexts.map((context) => safeId(context.id)),
870
+ ...profileServices.map((service) => fqn(service.id)),
871
+ ...profileStores.map((store) => fqn(store.id)),
872
+ ...profileBrokers,
873
+ ...profileOutside,
874
+ ])}`,
875
+ );
876
+ views.push(
877
+ ...containerPredicates(
878
+ [...callPairs.values()].filter(
879
+ (pair) =>
880
+ profileServiceIds.has(pair.from) && profileServiceIds.has(pair.to),
881
+ ),
882
+ carriedByBus.filter(
883
+ (pair) =>
884
+ profileServiceIds.has(pair.from) && profileServiceIds.has(pair.to),
885
+ ),
886
+ " ",
887
+ ),
888
+ );
752
889
  views.push(" }");
753
890
  views.push("");
754
891
  }
@@ -766,7 +903,8 @@ for (const context of catalog.contexts) {
766
903
  const contextStores = new Set();
767
904
  const contextServiceIds = new Set(context.services.map((s) => s.id));
768
905
  for (const service of context.services) {
769
- for (const store of storesByOwner.get(service.id) ?? []) contextStores.add(store.id);
906
+ for (const store of storesByOwner.get(service.id) ?? [])
907
+ contextStores.add(store.id);
770
908
  for (const storeId of service.stores ?? []) {
771
909
  if (storeById.has(storeId)) contextStores.add(storeId);
772
910
  }
@@ -775,12 +913,17 @@ for (const context of catalog.contexts) {
775
913
  // Only the pairs the picture holds both ends of: a neighbour in another
776
914
  // context is drawn folded into its context, and naming one of its services
777
915
  // would unfold it into a second box.
778
- const inside = ([from, to]) => contextServiceIds.has(from) && contextServiceIds.has(to);
779
- const pairs = [...callPairs.values()].filter((pair) => inside([pair.from, pair.to]));
916
+ const inside = ([from, to]) =>
917
+ contextServiceIds.has(from) && contextServiceIds.has(to);
918
+ const pairs = [...callPairs.values()].filter((pair) =>
919
+ inside([pair.from, pair.to]),
920
+ );
780
921
  views.push(` view ${contextViewId(context)} of ${safeId(context.id)} {`);
781
922
  views.push(` title ${q(context.name)}`);
782
923
  views.push(` include ${include}`);
783
- views.push(...containerPredicates(pairs, carriedByBus.filter(inside), " "));
924
+ views.push(
925
+ ...containerPredicates(pairs, carriedByBus.filter(inside), " "),
926
+ );
784
927
  views.push(" }");
785
928
 
786
929
  for (const service of context.services) {
@@ -791,7 +934,9 @@ for (const context of catalog.contexts) {
791
934
  // read from inside `auth.auth` resolves to nothing at all.
792
935
  const parts = [
793
936
  ...service.aggregates.map((a) => safeId(a.slug)),
794
- ...(storesByOwner.get(service.id) ?? []).map((store) => safeId(store.slug)),
937
+ ...(storesByOwner.get(service.id) ?? []).map((store) =>
938
+ safeId(store.slug),
939
+ ),
795
940
  ];
796
941
  views.push(` view ${serviceViewId(service)} of ${fqn(service.id)} {`);
797
942
  views.push(` title ${q(`${service.name} — neighbours`)}`);
@@ -803,7 +948,9 @@ for (const context of catalog.contexts) {
803
948
  // Its aggregates and its stores. Events are in the model but not in this
804
949
  // picture: a service with eleven of them would draw a wall of boxes where
805
950
  // the page already lists them, one line each.
806
- views.push(` view ${serviceInsideViewId(service)} of ${fqn(service.id)} {`);
951
+ views.push(
952
+ ` view ${serviceInsideViewId(service)} of ${fqn(service.id)} {`,
953
+ );
807
954
  views.push(` title ${q(`${service.name} — inside`)}`);
808
955
  views.push(" include *");
809
956
  views.push(" }");
@@ -814,12 +961,16 @@ views.push("");
814
961
  for (const flow of catalog.flows) {
815
962
  const contexts = new Map(flow.participants.map((p) => [p.id, p.context]));
816
963
  const contextOf = (id) => contexts.get(id) ?? null;
964
+ const replied = new Set();
965
+ walkFlowSteps(flow.steps, (step) => {
966
+ if (step.kind === "response" && step.replyTo) replied.add(step.replyTo);
967
+ });
817
968
 
818
969
  views.push(` dynamic view ${flowViewId(flow)} {`);
819
970
  views.push(` title ${q(flow.name)}`);
820
971
  views.push(` description ${q(flow.summary)}`);
821
972
  const body = [];
822
- emitSteps(flow.steps, body, " ");
973
+ emitSteps(flow.steps, body, " ", replied);
823
974
  views.push(...body);
824
975
  views.push(" }");
825
976
  views.push("");
@@ -828,7 +979,7 @@ for (const flow of catalog.flows) {
828
979
  views.push(` dynamic view ${flowCrossViewId(flow)} {`);
829
980
  views.push(` title ${q(`${flow.name} — crossings only`)}`);
830
981
  const crossBody = [];
831
- emitSteps(cross, crossBody, " ");
982
+ emitSteps(cross, crossBody, " ", replied);
832
983
  if (crossBody.length === 0) {
833
984
  // A flow with no crossing at all still needs a renderable view.
834
985
  const first = flow.participants[0];
@@ -845,8 +996,14 @@ views.push("}");
845
996
 
846
997
  mkdirSync("likec4", { recursive: true });
847
998
  writeFileSync("likec4/spec.c4", `${spec.join("\n")}\n`);
848
- writeFileSync("likec4/model.c4", `// GENERATED — do not edit.\n${model.join("\n")}\n`);
849
- writeFileSync("likec4/views.c4", `// GENERATED — do not edit.\n${views.join("\n")}\n`);
999
+ writeFileSync(
1000
+ "likec4/model.c4",
1001
+ `// GENERATED — do not edit.\n${model.join("\n")}\n`,
1002
+ );
1003
+ writeFileSync(
1004
+ "likec4/views.c4",
1005
+ `// GENERATED — do not edit.\n${views.join("\n")}\n`,
1006
+ );
850
1007
 
851
1008
  console.log(
852
1009
  `wrote likec4/spec.c4, likec4/model.c4, likec4/views.c4 ` +