@shortlink-org/portolan 0.2.3 → 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 (47) hide show
  1. package/package.json +1 -1
  2. package/plugins/README.md +5 -0
  3. package/plugins/extract-python-kafka/README.md +6 -0
  4. package/plugins/extract-python-kafka/extract.py +2 -2
  5. package/plugins/extract-python-kafka/extract_test.py +17 -1
  6. package/plugins/portolan-go.wasm +0 -0
  7. package/plugins/pyplugin/catalog.py +12 -1
  8. package/plugins/pyplugin/kafka.py +74 -3
  9. package/scripts/gen-likec4.mjs +78 -20
  10. package/scripts/gen-likec4.test.mjs +25 -2
  11. package/src/app/Breadcrumbs.test.ts +4 -0
  12. package/src/app/Breadcrumbs.tsx +1 -0
  13. package/src/catalog-model.ts +22 -1
  14. package/src/catalog-stores.test.ts +17 -0
  15. package/src/catalog-validation.ts +43 -2
  16. package/src/catalog.test.ts +13 -2
  17. package/src/components/ChannelRows.messagepack.test.tsx +28 -0
  18. package/src/components/ChannelRows.test.tsx +54 -0
  19. package/src/components/ChannelRows.tsx +57 -10
  20. package/src/components/LifecycleDiagram.tsx +28 -12
  21. package/src/components/ProblemRow.tsx +4 -0
  22. package/src/components/WhatLinksHere.tsx +6 -4
  23. package/src/enrich.test.ts +4 -5
  24. package/src/flow/StepDetail.tsx +98 -54
  25. package/src/flow/answers.test.ts +18 -1
  26. package/src/flow/answers.ts +37 -8
  27. package/src/lib/backlinks.test.ts +16 -1
  28. package/src/lib/backlinks.ts +20 -0
  29. package/src/lib/catalog-diff.test.ts +18 -0
  30. package/src/lib/catalog-diff.ts +19 -1
  31. package/src/lib/derive.ts +1 -0
  32. package/src/lib/kafka-ui.test.ts +87 -0
  33. package/src/lib/kafka-ui.ts +105 -0
  34. package/src/lib/wire-problems.test.ts +21 -0
  35. package/src/lib/wire-problems.ts +62 -1
  36. package/src/likec4/FlowView.tsx +2 -6
  37. package/src/likec4/flow-edges.test.ts +64 -1
  38. package/src/likec4/flow-edges.ts +43 -7
  39. package/src/likec4/view-index.ts +8 -2
  40. package/src/merge.test.ts +23 -0
  41. package/src/merge.ts +17 -1
  42. package/src/pages/CatalogFailure.tsx +2 -2
  43. package/src/pages/Settings.tsx +11 -3
  44. package/src/pages/settings/IntegrationsSettings.tsx +117 -0
  45. package/src/routes.test.ts +2 -0
  46. package/src/routes.ts +2 -1
  47. package/src/selection/DetailPanel.tsx +46 -1
@@ -105,7 +105,7 @@ describe("validateCatalog", () => {
105
105
  if (!step) throw new Error("fixture has no steps");
106
106
  step.ref = "shop.oms.order.NoSuchEvent";
107
107
  expect(() => validateCatalog(bad)).toThrowError(
108
- /resolves to neither an Event nor an RpcCall/,
108
+ /resolves to neither an Event, an RpcCall nor a method/,
109
109
  );
110
110
  step.status = "unresolved";
111
111
  expect(() => validateCatalog(bad)).not.toThrow();
@@ -1077,7 +1077,10 @@ describe("validateCatalog: channels", () => {
1077
1077
  }
1078
1078
 
1079
1079
  it("accepts a service that declares what it says on the bus", () => {
1080
- expect(() => validateCatalog(speaking())).not.toThrow();
1080
+ const good = speaking();
1081
+ good.contexts[0]!.services[0]!.channels![0]!.messages[0]!.encoding = "msgpack";
1082
+ good.contexts[0]!.services[0]!.channels![0]!.messages[0]!.contentType = "application/msgpack";
1083
+ expect(() => validateCatalog(good)).not.toThrow();
1081
1084
  });
1082
1085
 
1083
1086
  // A service with no document is the normal case, and it is not a service
@@ -1130,6 +1133,14 @@ describe("validateCatalog: channels", () => {
1130
1133
 
1131
1134
  expect(() => validateCatalog(bad)).toThrow(/neither send nor receive/);
1132
1135
  });
1136
+
1137
+ it("rejects an explicitly empty message encoding", () => {
1138
+ const bad = speaking();
1139
+ const message = bad.contexts[0]!.services[0]!.channels![0]!.messages[0]!;
1140
+ message.encoding = "";
1141
+
1142
+ expect(() => validateCatalog(bad)).toThrow(/empty encoding/);
1143
+ });
1133
1144
  });
1134
1145
 
1135
1146
  // A system outside the estate with a contract sits at the root beside the
@@ -0,0 +1,28 @@
1
+ import { renderToStaticMarkup } from "react-dom/server";
2
+ import { MemoryRouter } from "react-router";
3
+ import { describe, expect, it } from "vitest";
4
+ import type { Channel } from "../catalog";
5
+ import { ChannelRows } from "./ChannelRows";
6
+
7
+ describe("ChannelRows MessagePack presentation", () => {
8
+ it("shows a machine-readable encoding with its declared content type", () => {
9
+ const channel: Channel = {
10
+ address: "inventory.snapshots",
11
+ kind: "message",
12
+ messages: [{
13
+ name: "inventory.Snapshot",
14
+ direction: "send",
15
+ encoding: "msgpack",
16
+ contentType: "application/msgpack",
17
+ }],
18
+ };
19
+ const markup = renderToStaticMarkup(
20
+ <MemoryRouter>
21
+ <ChannelRows channels={[channel]} service="shop.inventory" />
22
+ </MemoryRouter>,
23
+ );
24
+
25
+ expect(markup).toContain('title="application/msgpack"');
26
+ expect(markup).toContain(">msgpack</span>");
27
+ });
28
+ });
@@ -0,0 +1,54 @@
1
+ import { renderToStaticMarkup } from "react-dom/server";
2
+ import { MemoryRouter } from "react-router";
3
+ import { describe, expect, it } from "vitest";
4
+ import type { Channel } from "../catalog";
5
+ import { ChannelRowsContent } from "./ChannelRows";
6
+
7
+ const kafka: Channel = {
8
+ address: "orders/created",
9
+ kind: "message",
10
+ title: "Kafka · orders/created",
11
+ messages: [],
12
+ };
13
+
14
+ const nats: Channel = {
15
+ address: "orders.created",
16
+ kind: "message",
17
+ title: "JetStream subject",
18
+ messages: [],
19
+ };
20
+
21
+ describe("ChannelRows Kafka UI integration", () => {
22
+ it("links only Kafka cards to their exact topic when configured", () => {
23
+ const markup = renderToStaticMarkup(
24
+ <MemoryRouter>
25
+ <ChannelRowsContent
26
+ channels={[kafka, nats]}
27
+ service="shop.orders"
28
+ kafkaUi="https://ops.example/ui/clusters/prod"
29
+ />
30
+ </MemoryRouter>,
31
+ );
32
+
33
+ expect(markup).toContain("view in Kafka UI");
34
+ expect(markup).toContain(
35
+ 'href="https://ops.example/ui/clusters/prod/all-topics/orders%2Fcreated"',
36
+ );
37
+ expect(markup.match(/view in Kafka UI/g)).toHaveLength(1);
38
+ });
39
+
40
+ it("does not offer an operational link before Kafka UI is configured", () => {
41
+ const markup = renderToStaticMarkup(
42
+ <MemoryRouter>
43
+ <ChannelRowsContent
44
+ channels={[kafka]}
45
+ service="shop.orders"
46
+ kafkaUi=""
47
+ />
48
+ </MemoryRouter>,
49
+ );
50
+
51
+ expect(markup).not.toContain("view in Kafka UI");
52
+ });
53
+
54
+ });
@@ -13,13 +13,22 @@
13
13
  // Problems page says which; a row that quietly dropped it would be the site
14
14
  // hiding the interesting case.
15
15
 
16
+ import { ExternalLink } from "lucide-react";
16
17
  import { Link } from "react-router";
17
18
  import type { Channel, ChannelMessage } from "../catalog";
18
- import { index } from "../data";
19
+ import { catalog, index } from "../data";
20
+ import {
21
+ isKafkaChannel,
22
+ kafkaHandoffChannels,
23
+ kafkaUiTopicUrl,
24
+ useKafkaUi,
25
+ } from "../lib/kafka-ui";
19
26
  import { eventPath } from "../routes";
20
27
  import { Ident } from "./Ident";
21
28
  import { RowActions } from "./RowActions";
22
29
 
30
+ const KAFKA_HANDOFF_CHANNELS = kafkaHandoffChannels(catalog);
31
+
23
32
  /** The event that goes out under a wire name, when the catalog knows one. */
24
33
  function publisherOf(name: string) {
25
34
  const event = index.eventByWireName.get(name);
@@ -75,6 +84,11 @@ function MessageRow({
75
84
  {elsewhere}
76
85
  </span>
77
86
  ) : null}
87
+ {message.encoding || message.contentType ? (
88
+ <span className="chip mono" title={message.contentType || "payload encoding"}>
89
+ {message.encoding || message.contentType}
90
+ </span>
91
+ ) : null}
78
92
  </div>
79
93
  {message.title || message.doc ? (
80
94
  <p className="mt-0.5 text-muted">{message.doc || message.title}</p>
@@ -91,20 +105,53 @@ export function ChannelRows({
91
105
  }: {
92
106
  channels: Channel[];
93
107
  service: string;
108
+ }) {
109
+ const kafkaUi = useKafkaUi((state) => state.url);
110
+ return (
111
+ <ChannelRowsContent
112
+ channels={channels}
113
+ service={service}
114
+ kafkaUi={kafkaUi}
115
+ />
116
+ );
117
+ }
118
+
119
+ /** The presentational half is exported so the generated links can be rendered in isolation. */
120
+ export function ChannelRowsContent({
121
+ channels,
122
+ service,
123
+ kafkaUi,
124
+ }: {
125
+ channels: Channel[];
126
+ service: string;
127
+ kafkaUi: string;
94
128
  }) {
95
129
  return (
96
130
  <div className="flex flex-col gap-section" data-nav-list>
97
131
  {channels.map((channel) => (
98
132
  <div key={channel.address} className="rounded-card border border-line">
99
- <div className="flex flex-wrap items-baseline gap-x-2 border-b border-line px-3 py-2">
100
- <Ident value={channel.address} />
101
- {channel.kind === "job" ? (
102
- <span className="chip">work queue</span>
103
- ) : channel.kind === "message" ? (
104
- <span className="chip">message stream</span>
105
- ) : null}
106
- {channel.title ? (
107
- <span className="text-muted">{channel.title}</span>
133
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-2 border-b border-line px-3 py-2">
134
+ <div className="flex min-w-0 flex-1 flex-wrap items-baseline gap-x-2">
135
+ <Ident value={channel.address} />
136
+ {channel.kind === "job" ? (
137
+ <span className="chip">work queue</span>
138
+ ) : channel.kind === "message" ? (
139
+ <span className="chip">message stream</span>
140
+ ) : null}
141
+ {channel.title ? (
142
+ <span className="text-muted">{channel.title}</span>
143
+ ) : null}
144
+ </div>
145
+ {kafkaUi && isKafkaChannel(channel, KAFKA_HANDOFF_CHANNELS) ? (
146
+ <a
147
+ href={kafkaUiTopicUrl(kafkaUi, channel.address) ?? kafkaUi}
148
+ target="_blank"
149
+ rel="noreferrer"
150
+ className="mono inline-flex shrink-0 items-center gap-1 whitespace-nowrap rounded-control text-accent hover:underline"
151
+ title={`Open ${channel.address} in Kafka UI`}
152
+ >
153
+ view in Kafka UI <ExternalLink size={12} aria-hidden />
154
+ </a>
108
155
  ) : null}
109
156
  </div>
110
157
  {channel.doc ? (
@@ -3,6 +3,12 @@ import type { Aggregate } from "../catalog";
3
3
  import { layoutLifecycle, METRICS } from "../lib/lifecycle";
4
4
  import { KindIcon } from "./kind";
5
5
 
6
+ function transitionTone(on: string) {
7
+ if (on === "Fail") return { color: "var(--status-unresolved)", marker: "lc-arrow-fail" };
8
+ if (on === "Succeed") return { color: "var(--status-verified)", marker: "lc-arrow-succeed" };
9
+ return { color: "var(--fg-muted)", marker: "lc-arrow" };
10
+ }
11
+
6
12
  /**
7
13
  * The aggregate's state machine, as the code wrote it down. Boxes and arrows
8
14
  * are SVG; the labels are HTML laid over it, because a label holds a link to
@@ -29,18 +35,27 @@ export function LifecycleDiagram({
29
35
  <marker id="lc-arrow" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
30
36
  <path d="M 0 0 L 8 4 L 0 8 z" fill="var(--fg-muted)" />
31
37
  </marker>
38
+ <marker id="lc-arrow-fail" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
39
+ <path d="M 0 0 L 8 4 L 0 8 z" fill="var(--status-unresolved)" />
40
+ </marker>
41
+ <marker id="lc-arrow-succeed" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
42
+ <path d="M 0 0 L 8 4 L 0 8 z" fill="var(--status-verified)" />
43
+ </marker>
32
44
  </defs>
33
- {edges.map((e) => (
34
- <path
35
- key={`${e.from}-${e.to}-${e.on}`}
36
- d={e.path}
37
- fill="none"
38
- stroke="var(--fg-muted)"
39
- strokeWidth={1.25}
40
- strokeDasharray={e.back ? "4 3" : undefined}
41
- markerEnd="url(#lc-arrow)"
42
- />
43
- ))}
45
+ {edges.map((e) => {
46
+ const tone = transitionTone(e.on);
47
+ return (
48
+ <path
49
+ key={`${e.from}-${e.to}-${e.on}`}
50
+ d={e.path}
51
+ fill="none"
52
+ stroke={tone.color}
53
+ strokeWidth={1.25}
54
+ strokeDasharray={e.back ? "4 3" : undefined}
55
+ markerEnd={`url(#${tone.marker})`}
56
+ />
57
+ );
58
+ })}
44
59
  {boxes.map((b) => (
45
60
  <g key={b.state}>
46
61
  <rect
@@ -80,11 +95,12 @@ export function LifecycleDiagram({
80
95
  ))}
81
96
  {edges.map((e) => {
82
97
  const to = e.emits ? eventPath(e.emits) : null;
98
+ const tone = transitionTone(e.on);
83
99
  return (
84
100
  <div
85
101
  key={`${e.from}-${e.to}-${e.on}-label`}
86
102
  className="mono absolute flex -translate-x-1/2 -translate-y-1/2 items-center gap-1 whitespace-nowrap rounded-control bg-bg px-1 text-muted"
87
- style={{ left: e.labelX, top: e.labelY }}
103
+ style={{ left: e.labelX, top: e.labelY, color: tone.color }}
88
104
  title={e.source ? `made at ${e.source}` : undefined}
89
105
  >
90
106
  {e.on}
@@ -31,6 +31,7 @@ const KIND_OF: Record<Problem["kind"], "service" | "event" | "table"> = {
31
31
  "shared-channel": "event",
32
32
  "channel-undeclared": "event",
33
33
  "channel-unpublished": "service",
34
+ "message-encoding": "service",
34
35
  "subscription-unresolved": "service",
35
36
  };
36
37
 
@@ -50,6 +51,7 @@ const KIND_NOTE: Record<Problem["kind"], string> = {
50
51
  "channel-undeclared":
51
52
  "this event goes out on a channel the service does not declare",
52
53
  "channel-unpublished": "a declared channel no event of this service names",
54
+ "message-encoding": "publisher and subscriber use different payload encodings",
53
55
  "subscription-unresolved":
54
56
  "nothing in the catalog publishes what this service listens for",
55
57
  };
@@ -72,6 +74,7 @@ function nearPath(problem: Problem): string | null {
72
74
  case "shared-channel":
73
75
  return eventPath(problem.id) ?? servicePath(problem.service);
74
76
  case "channel-unpublished":
77
+ case "message-encoding":
75
78
  case "subscription-unresolved":
76
79
  return servicePath(problem.service);
77
80
  case "shared-store":
@@ -110,6 +113,7 @@ function peerPath(problem: Problem): string | null {
110
113
  );
111
114
  case "shared-store":
112
115
  case "shared-channel":
116
+ case "message-encoding":
113
117
  return servicePath(problem.peer);
114
118
  case "outbox-payload":
115
119
  return storePath(problem.peer);
@@ -14,7 +14,11 @@ import { useMemo } from "react";
14
14
  import type { ReactNode } from "react";
15
15
  import { Link } from "react-router";
16
16
  import { catalog, index } from "../data";
17
- import { backlinkCount, backlinksFor } from "../lib/backlinks";
17
+ import {
18
+ backlinkCount,
19
+ backlinksFor,
20
+ distinctBacklinks,
21
+ } from "../lib/backlinks";
18
22
  import type { Backlink, BacklinkGroup, BacklinkTarget } from "../lib/backlinks";
19
23
  import { plural } from "../lib/format";
20
24
  import { KIND_LABEL, KIND_PLURAL } from "../lib/kinds";
@@ -163,9 +167,7 @@ export function WhatLinksHere({
163
167
  className={`mono flex flex-wrap items-center gap-1.5 text-muted ${className || "mt-2"}`}
164
168
  >
165
169
  linked from
166
- {groups
167
- .flatMap((g) => g.links)
168
- .map((link) => {
170
+ {distinctBacklinks(groups).map((link) => {
169
171
  const to = backlinkPath(link);
170
172
  const body = (
171
173
  <>
@@ -864,14 +864,13 @@ describe("enrichCatalog: calls from rpc steps", () => {
864
864
  expect(() => validateCatalog(catalog)).not.toThrow();
865
865
  });
866
866
 
867
- it("is what lets a step naming a provided method validate: the call is now known", () => {
868
- // The validator resolves a step's ref against declared calls, not against
869
- // what peers provide. Before this pass the step names a method no service
870
- // is on record as calling; after it, shop.oms is.
867
+ it("keeps a step naming a provided method valid while recording its caller", () => {
868
+ // The provided method is enough to resolve the flow step. Enrichment adds
869
+ // the separate fact that shop.oms is one of its callers.
871
870
  const c = estate([
872
871
  flow("a", [step("shop.oms", "shop.pricing", "rpc", { ref: METHOD })]),
873
872
  ]);
874
- expect(() => validateCatalog(c)).toThrow(/resolves to neither/);
873
+ expect(() => validateCatalog(c)).not.toThrow();
875
874
  expect(() => validateCatalog(enrichCatalog(c).catalog)).not.toThrow();
876
875
  });
877
876
 
@@ -1,18 +1,17 @@
1
1
  import { useMemo } from "react";
2
2
  import { Link } from "react-router";
3
- import { AlertTriangle } from "lucide-react";
3
+ import { AlertTriangle, FileCode2 } from "lucide-react";
4
4
  import { allRepos, stepFrames } from "../catalog";
5
5
  import type { Flow, Step, StepFrame } from "../catalog";
6
6
  import { catalog, index } from "../data";
7
7
  import { Ident } from "../components/Ident";
8
- import { EditorLink } from "../components/EditorLink";
9
- import { SourcePreviewButton } from "../components/SourcePreview";
8
+ import { SourcePreviewLink } from "../components/SourcePreview";
10
9
  import { flowRepoService } from "../lib/derive";
11
10
  import { sourceLocation } from "../lib/source-link";
12
11
  import { AdrNumber, StatusChip } from "../components/primitives";
13
12
  import { ShapeRows } from "../components/ShapeRows";
14
13
  import { shapeFor } from "../components/MethodRows";
15
- import { stepAnswer } from "./answers";
14
+ import { stepAnswer, stepRpcContract } from "./answers";
16
15
  import {
17
16
  aggregatePath,
18
17
  paths,
@@ -154,12 +153,28 @@ function EventDetail({ step, flow }: { step: Step; flow: Flow }) {
154
153
  }
155
154
 
156
155
  function RpcDetail({ step, flow }: { step: Step; flow: Flow }) {
157
- const method = step.ref ?? step.label ?? "(unknown method)";
156
+ const contract = stepRpcContract(index, step);
157
+ const method = contract?.id ?? step.ref ?? step.label ?? "(unknown method)";
158
158
  const call = step.ref ? index.rpcById.get(step.ref) : undefined;
159
- const provider = step.ref
160
- ? index.rpcProviderByMethod.get(step.ref)
159
+ const provider = contract?.provider;
160
+ const internalProvider = provider
161
+ ? index.serviceById.get(provider.id)
161
162
  : undefined;
162
- const providerPath = provider ? servicePath(provider.id) : null;
163
+ const providerPath = internalProvider ? servicePath(internalProvider.id) : null;
164
+ const requestFields = contract
165
+ ? shapeFor(
166
+ contract.provided,
167
+ contract.method.request,
168
+ contract.method.requestRef,
169
+ )
170
+ : null;
171
+ const responseFields = contract
172
+ ? shapeFor(
173
+ contract.provided,
174
+ contract.method.response,
175
+ contract.method.responseRef,
176
+ )
177
+ : null;
163
178
  // The flow records the hop; what comes back is the contract's to say.
164
179
  const answer = stepAnswer(index, step);
165
180
 
@@ -185,10 +200,12 @@ function RpcDetail({ step, flow }: { step: Step; flow: Flow }) {
185
200
  ) : null}
186
201
  <dt className="mono text-muted">Provider</dt>
187
202
  <dd>
188
- {provider && providerPath ? (
203
+ {internalProvider && providerPath ? (
189
204
  <Link to={providerPath} className="mono text-accent">
190
- {provider.id} →
205
+ {internalProvider.id} →
191
206
  </Link>
207
+ ) : provider ? (
208
+ <span className="mono text-ink">{provider.id}</span>
192
209
  ) : (
193
210
  <span className="mono inline-flex items-center gap-1.5 rounded-control border px-1.5 py-0.5 status-unresolved">
194
211
  <AlertTriangle size={11} aria-hidden />
@@ -205,6 +222,34 @@ function RpcDetail({ step, flow }: { step: Step; flow: Flow }) {
205
222
  </dl>
206
223
  </DetailSection>
207
224
 
225
+ {contract?.method.request ? (
226
+ <DetailSection title="Request" meta={contract.method.request}>
227
+ {requestFields ? (
228
+ <ShapeRows
229
+ fields={requestFields}
230
+ enums={contract.provided.enums}
231
+ showHeader
232
+ />
233
+ ) : (
234
+ <div className="mono text-muted">shape not recorded</div>
235
+ )}
236
+ </DetailSection>
237
+ ) : null}
238
+
239
+ {contract?.method.response ? (
240
+ <DetailSection title="Response" meta={contract.method.response}>
241
+ {responseFields ? (
242
+ <ShapeRows
243
+ fields={responseFields}
244
+ enums={contract.provided.enums}
245
+ showHeader
246
+ />
247
+ ) : (
248
+ <div className="mono text-muted">shape not recorded</div>
249
+ )}
250
+ </DetailSection>
251
+ ) : null}
252
+
208
253
  <DetailSection title="Source">
209
254
  {call ? (
210
255
  <SourceWhere where={call.source} flow={flow} structured />
@@ -525,48 +570,25 @@ function SourceWhere({
525
570
  allRepos(catalog),
526
571
  );
527
572
 
528
- const actions = (
529
- <>
530
- <SourcePreviewButton
531
- location={location}
532
- className="border px-2 py-1 border-line bg-canvas hover:bg-raised hover:no-underline"
533
- />
534
- {location?.href ? (
535
- <a
536
- href={location.href}
537
- target="_blank"
538
- rel="noreferrer"
539
- className="mono inline-flex items-center rounded-control border px-2 py-1 border-line bg-canvas text-accent hover:bg-raised"
540
- title="Open on the forge, at the built commit"
541
- >
542
- forge ↗
543
- </a>
544
- ) : null}
545
- <EditorLink
546
- location={location}
547
- variant="text"
548
- className="inline-flex items-center border px-2 py-1 border-line bg-canvas hover:bg-raised hover:no-underline"
549
- />
550
- </>
573
+ const source = (
574
+ <SourcePreviewLink
575
+ location={location}
576
+ className={
577
+ structured
578
+ ? "w-full min-w-0 border px-2.5 py-2 border-line bg-canvas text-ink hover:border-line-strong hover:bg-raised hover:no-underline"
579
+ : "max-w-full min-w-0 text-muted hover:text-accent"
580
+ }
581
+ >
582
+ <FileCode2 size={14} aria-hidden className="shrink-0" />
583
+ <span className="min-w-0 break-all text-left">{where}</span>
584
+ </SourcePreviewLink>
551
585
  );
552
586
 
553
587
  if (structured) {
554
- return (
555
- <div className="flex min-w-0 flex-col gap-2">
556
- <div className="mono min-w-0 break-all text-muted">
557
- <Ident block value={where} className="text-muted" />
558
- </div>
559
- <div className="flex flex-wrap items-center gap-1.5">{actions}</div>
560
- </div>
561
- );
588
+ return <div className="min-w-0">{source}</div>;
562
589
  }
563
590
 
564
- return (
565
- <div className="mono flex flex-wrap items-center gap-2 break-all text-muted">
566
- <Ident block value={where} className="text-muted" />
567
- {actions}
568
- </div>
569
- );
591
+ return <div className="min-w-0">{source}</div>;
570
592
  }
571
593
 
572
594
  function Where({ step, flow }: { step: Step; flow: Flow }) {
@@ -574,6 +596,34 @@ function Where({ step, flow }: { step: Step; flow: Flow }) {
574
596
  return <SourceWhere where={step.line} flow={flow} />;
575
597
  }
576
598
 
599
+ function CallDetail({ step, flow }: { step: Step; flow: Flow }) {
600
+ return (
601
+ <section
602
+ aria-label="Call detail"
603
+ className="overflow-hidden rounded-card border shadow-xs border-line"
604
+ >
605
+ <header className="border-b px-3 py-2.5 border-line bg-surface">
606
+ <h2 className="label">Call</h2>
607
+ <div className="mt-2">
608
+ <Ident
609
+ block
610
+ value={step.label ?? "internal call"}
611
+ className="text-ink"
612
+ />
613
+ </div>
614
+ </header>
615
+
616
+ <DetailSection title="Source">
617
+ {step.line ? (
618
+ <SourceWhere where={step.line} flow={flow} structured />
619
+ ) : (
620
+ <div className="mono text-muted">not recorded</div>
621
+ )}
622
+ </DetailSection>
623
+ </section>
624
+ );
625
+ }
626
+
577
627
  function StoreCallDetail({ step, flow }: { step: Step; flow: Flow }) {
578
628
  const access = step.storeAccess!;
579
629
  const store = index.storeById.get(access.store);
@@ -699,13 +749,7 @@ export function StepDetailBody({ step, flow }: { step: Step; flow: Flow }) {
699
749
  ) : step.storeAccess ? (
700
750
  <StoreCallDetail step={step} flow={flow} />
701
751
  ) : (
702
- <>
703
- <div className="mono text-[13px]">
704
- {step.label ?? "internal call"}
705
- </div>
706
- <Label>Source</Label>
707
- <Where step={step} flow={flow} />
708
- </>
752
+ <CallDetail step={step} flow={flow} />
709
753
  )}
710
754
 
711
755
  {step.note ? (
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import { catalog, index } from "../data";
3
3
  import { walkSteps } from "../catalog";
4
- import { flowAnswers, stepAnswer } from "./answers";
4
+ import { flowAnswers, stepAnswer, stepRpcContract } from "./answers";
5
5
 
6
6
  const flow = (slug: string) => {
7
7
  const found = catalog.flows.find((f) => f.slug === slug);
@@ -16,6 +16,23 @@ const step = (slug: string, id: string) => {
16
16
  };
17
17
 
18
18
  describe("stepAnswer", () => {
19
+ it("resolves an incoming endpoint to its full request and response contract", () => {
20
+ const contract = stepRpcContract(
21
+ index,
22
+ step("pricing-archive-price-list", "s1"),
23
+ );
24
+
25
+ expect(contract?.id).toBe("shop.v1.PriceLists/ArchivePriceList");
26
+ expect(contract?.provider.id).toBe("shop.pricing");
27
+ expect(contract?.method.request).toBe("ArchivePriceListRequest");
28
+ expect(contract?.method.response).toBe("ArchivePriceListResponse");
29
+ expect(
30
+ contract?.provided.messages?.find(
31
+ (message) => message.name === contract.method.request,
32
+ )?.fields.map((field) => field.name),
33
+ ).toEqual(["price_list_id"]);
34
+ });
35
+
19
36
  it("reads the answer of an endpoint off the document that declares it", () => {
20
37
  // billing's ViewSet is exposed by billing.v1.Invoices, and the document
21
38
  // says a void answers 204 and an issue answers with the invoice's id.