@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
@@ -128,6 +128,7 @@ export function MethodRows({
128
128
  open,
129
129
  onToggle,
130
130
  runs,
131
+ reference,
131
132
  }: {
132
133
  provided: RpcService;
133
134
  /** Which shapes are expanded, keyed `<interface>/<method>/req|res`. */
@@ -135,6 +136,8 @@ export function MethodRows({
135
136
  onToggle: (key: string) => void;
136
137
  /** What the method runs, when the page knows — the service page does. */
137
138
  runs?: (method: RpcMethod) => ReactNode;
139
+ /** Protocol-specific tools shown below the method, when its reference has one. */
140
+ reference?: (method: RpcMethod) => ReactNode;
138
141
  }) {
139
142
  return (
140
143
  <ul data-nav-list>
@@ -227,6 +230,8 @@ export function MethodRows({
227
230
 
228
231
  {runs?.(method)}
229
232
 
233
+ {reference?.(method)}
234
+
230
235
  {open.has(reqKey) && reqFields ? (
231
236
  <div className="mt-2">
232
237
  <DiscriminatorRows discriminator={reqDiscriminator} />
@@ -240,7 +245,9 @@ export function MethodRows({
240
245
  </div>
241
246
  ) : null}
242
247
  </div>
243
- <RowActions copy={id} />
248
+ <span className="self-start pt-0.5">
249
+ <RowActions copy={id} />
250
+ </span>
244
251
  </li>
245
252
  );
246
253
  })}
@@ -0,0 +1,24 @@
1
+ import { createElement } from "react";
2
+ import { renderToStaticMarkup } from "react-dom/server";
3
+ import { describe, expect, it } from "vitest";
4
+
5
+ import { CapabilityEmpty } from "./PageHeader";
6
+
7
+ describe("CapabilityEmpty", () => {
8
+ it("keeps the explanation, action and evidence signal together", () => {
9
+ const markup = renderToStaticMarkup(createElement(
10
+ CapabilityEmpty,
11
+ {
12
+ title: "No dependency graph yet",
13
+ actions: createElement("button", { type: "button" }, "Review extraction"),
14
+ signal: "Signals: domain events and consumers.",
15
+ },
16
+ "The catalog has components, but no events to connect.",
17
+ ));
18
+
19
+ expect(markup).toContain("next useful step");
20
+ expect(markup).toContain("No dependency graph yet");
21
+ expect(markup).toContain("Review extraction");
22
+ expect(markup).toContain("Signals: domain events and consumers.");
23
+ });
24
+ });
@@ -155,6 +155,40 @@ export function Empty({ children }: { children: ReactNode }) {
155
155
  return <div className="empty">{children}</div>;
156
156
  }
157
157
 
158
+ /**
159
+ * A page-level absence with a useful next move.
160
+ *
161
+ * Unlike `Empty`, this is not the result of a filter, and unlike `Blank` it
162
+ * does more than document the catalog field that is missing. It is used when
163
+ * the reader can take a concrete setup action to make the page useful.
164
+ */
165
+ export function CapabilityEmpty({
166
+ title,
167
+ children,
168
+ actions,
169
+ signal,
170
+ }: {
171
+ title: string;
172
+ children: ReactNode;
173
+ actions?: ReactNode;
174
+ /** The source evidence or catalog fact that will fill this page. */
175
+ signal?: ReactNode;
176
+ }) {
177
+ return (
178
+ <div className="rounded-card border border-accent/60 bg-surface p-5 shadow-xs">
179
+ <div className="label text-accent">next useful step</div>
180
+ <h2 className="mt-1 text-md font-semibold text-ink">{title}</h2>
181
+ <div className="mt-2 max-w-prose text-muted">{children}</div>
182
+ {actions ? <div className="mt-4 flex flex-wrap gap-2">{actions}</div> : null}
183
+ {signal ? (
184
+ <div className="mono mt-4 border-t border-line pt-3 text-faint">
185
+ {signal}
186
+ </div>
187
+ ) : null}
188
+ </div>
189
+ );
190
+ }
191
+
158
192
  /**
159
193
  * What a page says before the catalog has anything to put on it.
160
194
  *
@@ -44,10 +44,13 @@ function EnumRows({ set }: { set: RpcEnum }) {
44
44
  export function ShapeRows({
45
45
  fields,
46
46
  enums,
47
+ showHeader = false,
47
48
  }: {
48
49
  fields: Field[];
49
50
  /** The interface's enums, so a field typed by one can open its values. */
50
51
  enums?: RpcEnum[];
52
+ /** A contract panel needs named columns; compact inline shapes do not. */
53
+ showHeader?: boolean;
51
54
  }) {
52
55
  const [open, setOpen] = useState<Set<string>>(new Set());
53
56
  const toggle = (name: string) =>
@@ -60,6 +63,15 @@ export function ShapeRows({
60
63
 
61
64
  return (
62
65
  <table className="w-full">
66
+ {showHeader ? (
67
+ <thead>
68
+ <tr className="label border-b border-line text-left">
69
+ <th className="pb-1 pr-3 font-normal">Field</th>
70
+ <th className="pb-1 pr-3 font-normal">Type</th>
71
+ <th className="pb-1 font-normal">Description</th>
72
+ </tr>
73
+ </thead>
74
+ ) : null}
63
75
  <tbody>
64
76
  {fields.map((field) => {
65
77
  const set = enumFor(enums, field);
@@ -20,6 +20,7 @@ import { KindIcon } from "./kind";
20
20
  import { MessageList, MethodRows } from "./MethodRows";
21
21
  import { paths } from "../routes";
22
22
  import { packageOf } from "../lib/registry";
23
+ import { GrpcMethodReference } from "./GrpcMethodReference";
23
24
 
24
25
  /** The interfaces of one module, as the service page's spec tab shows them. */
25
26
  export function ModuleSpec({ moduleId }: { moduleId: string }) {
@@ -46,17 +47,24 @@ export function ModuleSpec({ moduleId }: { moduleId: string }) {
46
47
 
47
48
  return (
48
49
  <div className="max-w-table">
49
- <div className="mono flex flex-wrap items-center gap-x-3">
50
- <KindIcon kind="module" />
51
- <Link
52
- to={paths.module(module.slug)}
53
- className="text-ink hover:underline"
54
- >
55
- {module.name}
56
- </Link>
57
- {module.commit ? (
58
- <Ident value={module.commit.slice(0, 12)} className="text-muted" />
59
- ) : null}
50
+ <div className="rounded-card border border-line bg-surface px-4 py-3">
51
+ <div className="mono flex flex-wrap items-center gap-x-3">
52
+ <KindIcon kind="module" />
53
+ <span className="chip">gRPC</span>
54
+ <Link
55
+ to={paths.module(module.slug)}
56
+ className="text-ink hover:underline"
57
+ >
58
+ {module.name}
59
+ </Link>
60
+ {module.commit ? (
61
+ <Ident value={module.commit.slice(0, 12)} className="text-muted" />
62
+ ) : null}
63
+ </div>
64
+ <p className="mt-1.5 text-muted">
65
+ Methods and message shapes extracted from Protocol Buffers. Open a
66
+ method&apos;s local call to copy a ProtoJSON body or a grpcurl command.
67
+ </p>
60
68
  </div>
61
69
 
62
70
  {declared.length === 0 ? (
@@ -72,7 +80,14 @@ export function ModuleSpec({ moduleId }: { moduleId: string }) {
72
80
  <div className="mono border-b px-3 py-1.5 border-line bg-surface">
73
81
  <Ident value={provided.id} className="text-ink" />
74
82
  </div>
75
- <MethodRows provided={provided} open={open} onToggle={toggle} />
83
+ <MethodRows
84
+ provided={provided}
85
+ open={open}
86
+ onToggle={toggle}
87
+ reference={(method) => (
88
+ <GrpcMethodReference provided={provided} method={method} />
89
+ )}
90
+ />
76
91
  <MessageList provided={provided} open={open} onToggle={toggle} />
77
92
  </div>
78
93
  </section>
@@ -6,7 +6,7 @@ import {
6
6
  Pin,
7
7
  X,
8
8
  } from "lucide-react";
9
- import { EditorLink } from "./EditorLink";
9
+ import { EditorLink, useEditorTarget } from "./EditorLink";
10
10
  import {
11
11
  useCallback,
12
12
  useEffect,
@@ -140,7 +140,8 @@ function CodeWindow({
140
140
  [file.content, location.line],
141
141
  );
142
142
  const highlighted = useMemo(
143
- () => highlightSource(location.path, rows.map((row) => row.text).join("\n")),
143
+ () =>
144
+ highlightSource(location.path, rows.map((row) => row.text).join("\n")),
144
145
  [location.path, rows],
145
146
  );
146
147
  const focusedIndex = rows.findIndex((row) => row.focused);
@@ -149,7 +150,9 @@ function CodeWindow({
149
150
  if (node.type === "text") return node.value;
150
151
  const names = node.properties?.className;
151
152
  const className = Array.isArray(names)
152
- ? names.filter((name): name is string => typeof name === "string").join(" ")
153
+ ? names
154
+ .filter((name): name is string => typeof name === "string")
155
+ .join(" ")
153
156
  : typeof names === "string"
154
157
  ? names
155
158
  : undefined;
@@ -187,7 +190,9 @@ function CodeWindow({
187
190
  ))}
188
191
  </div>
189
192
  <pre className="source-code relative z-10 min-w-max pl-3 pr-4 text-ink">
190
- <code>{highlighted.map((node, index) => renderNode(node, String(index)))}</code>
193
+ <code>
194
+ {highlighted.map((node, index) => renderNode(node, String(index)))}
195
+ </code>
191
196
  </pre>
192
197
  </div>
193
198
  </div>
@@ -315,18 +320,25 @@ function PreviewPanel({
315
320
  );
316
321
  }
317
322
 
318
- /** Hover/focus for a glance; click pins the same preview for scrolling and copying. */
319
- export function SourcePreviewButton({
323
+ function SourcePreviewTrigger({
320
324
  location,
325
+ children,
321
326
  className = "",
327
+ href,
328
+ external = false,
329
+ title,
322
330
  }: {
323
- location: SourceLocation | null;
331
+ location: SourceLocation;
332
+ children: ReactNode;
324
333
  className?: string;
334
+ href?: string;
335
+ external?: boolean;
336
+ title: string;
325
337
  }) {
326
338
  const [open, setOpen] = useState(false);
327
339
  const [pinned, setPinned] = useState(false);
328
340
  const [position, setPosition] = useState<Position | null>(null);
329
- const triggerRef = useRef<HTMLButtonElement>(null);
341
+ const triggerRef = useRef<HTMLElement | null>(null);
330
342
  const panelRef = useRef<HTMLDivElement>(null);
331
343
  const pinnedRef = useRef(false);
332
344
  const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -386,41 +398,59 @@ export function SourcePreviewButton({
386
398
  return () => document.removeEventListener("pointerdown", outside);
387
399
  }, [close, pinned]);
388
400
 
389
- if (!location) return null;
390
-
391
401
  const hover = (event: ReactPointerEvent) => {
392
402
  if (event.pointerType === "mouse" || event.pointerType === "pen")
393
403
  openSoon();
394
404
  };
395
405
 
406
+ const triggerProps = {
407
+ "aria-haspopup": "dialog" as const,
408
+ "aria-expanded": open,
409
+ "aria-controls": open ? id : undefined,
410
+ className: `mono inline-flex items-center gap-1 rounded-control text-accent hover:underline focus-visible:outline focus-visible:outline-1 focus-visible:outline-accent ${className}`,
411
+ title,
412
+ onPointerEnter: hover,
413
+ onPointerLeave: closeSoon,
414
+ onFocus: () => setOpen(true),
415
+ onBlur: closeSoon,
416
+ };
417
+
396
418
  return (
397
419
  <>
398
- <button
399
- ref={triggerRef}
400
- type="button"
401
- aria-haspopup="dialog"
402
- aria-expanded={open}
403
- aria-controls={open ? id : undefined}
404
- className={`mono inline-flex items-center gap-1 rounded-control text-accent hover:underline focus-visible:outline focus-visible:outline-1 focus-visible:outline-accent ${className}`}
405
- title="Preview source · click to pin"
406
- onPointerEnter={hover}
407
- onPointerLeave={closeSoon}
408
- onFocus={() => setOpen(true)}
409
- onBlur={closeSoon}
410
- onClick={() => {
411
- cancelTimer();
412
- if (open && pinned) {
413
- close();
414
- return;
415
- }
416
- setOpen(true);
417
- pinnedRef.current = true;
418
- setPinned(true);
419
- setTimeout(() => panelRef.current?.focus(), 0);
420
- }}
421
- >
422
- <Code2 size={13} aria-hidden /> preview
423
- </button>
420
+ {href ? (
421
+ <a
422
+ ref={(node) => {
423
+ triggerRef.current = node;
424
+ }}
425
+ href={href}
426
+ target={external ? "_blank" : undefined}
427
+ rel={external ? "noreferrer" : undefined}
428
+ {...triggerProps}
429
+ >
430
+ {children}
431
+ </a>
432
+ ) : (
433
+ <button
434
+ ref={(node) => {
435
+ triggerRef.current = node;
436
+ }}
437
+ type="button"
438
+ {...triggerProps}
439
+ onClick={() => {
440
+ cancelTimer();
441
+ if (open && pinned) {
442
+ close();
443
+ return;
444
+ }
445
+ setOpen(true);
446
+ pinnedRef.current = true;
447
+ setPinned(true);
448
+ setTimeout(() => panelRef.current?.focus(), 0);
449
+ }}
450
+ >
451
+ {children}
452
+ </button>
453
+ )}
424
454
  {open && position && typeof document !== "undefined"
425
455
  ? createPortal(
426
456
  <PreviewPanel
@@ -439,3 +469,61 @@ export function SourcePreviewButton({
439
469
  </>
440
470
  );
441
471
  }
472
+
473
+ /** Hover/focus for a glance; click pins the same preview for scrolling and copying. */
474
+ export function SourcePreviewButton({
475
+ location,
476
+ className = "",
477
+ }: {
478
+ location: SourceLocation | null;
479
+ className?: string;
480
+ }) {
481
+ if (!location) return null;
482
+ return (
483
+ <SourcePreviewTrigger
484
+ location={location}
485
+ className={className}
486
+ title="Preview source · click to pin"
487
+ >
488
+ <Code2 size={13} aria-hidden /> preview
489
+ </SourcePreviewTrigger>
490
+ );
491
+ }
492
+
493
+ /**
494
+ * A compact source path: preview on hover, then open the configured editor in
495
+ * local mode or the immutable forge location in a static build.
496
+ */
497
+ export function SourcePreviewLink({
498
+ location,
499
+ children,
500
+ className = "",
501
+ }: {
502
+ location: SourceLocation | null;
503
+ children: ReactNode;
504
+ className?: string;
505
+ }) {
506
+ const editor = useEditorTarget(location);
507
+ if (!location) return <span className={className}>{children}</span>;
508
+ const href = editor?.href ?? location.href ?? undefined;
509
+ const destination = editor
510
+ ? editor.name
511
+ : location.href
512
+ ? "repository"
513
+ : null;
514
+ return (
515
+ <SourcePreviewTrigger
516
+ location={location}
517
+ className={className}
518
+ href={href}
519
+ external={!editor && Boolean(location.href)}
520
+ title={
521
+ destination
522
+ ? `Preview on hover · click to open in ${destination}`
523
+ : "Preview source · click to pin"
524
+ }
525
+ >
526
+ {children}
527
+ </SourcePreviewTrigger>
528
+ );
529
+ }
@@ -88,7 +88,14 @@ function pricing(): Service {
88
88
  provides: [
89
89
  {
90
90
  id: "pricing.v1.Pricing",
91
- methods: [{ name: "Quote", doc: "" }],
91
+ methods: [
92
+ {
93
+ name: "Quote",
94
+ doc: "",
95
+ request: "QuoteRequest",
96
+ response: "QuoteResponse",
97
+ },
98
+ ],
92
99
  source: "pricing.proto",
93
100
  },
94
101
  ],
@@ -113,7 +120,7 @@ beforeEach(() => {
113
120
  function step(
114
121
  from: string,
115
122
  to: string,
116
- kind: "rpc" | "event" | "call",
123
+ kind: "rpc" | "event" | "call" | "response",
117
124
  extra: Partial<Extract<FlowNode, { type: "step" }>> = {},
118
125
  ): FlowNode {
119
126
  n += 1;
@@ -287,6 +294,7 @@ describe("enrichCatalog: asynchronous outbound continuations", () => {
287
294
  "enqueue void",
288
295
  "VoidWorker.Work",
289
296
  "POST /cancel",
297
+ "HTTP response",
290
298
  ]);
291
299
  expect(root.includes).toEqual(["void-job", "void-http"]);
292
300
  expect(once.flows.map((item) => item.slug)).toEqual([
@@ -345,6 +353,7 @@ describe("enrichCatalog: asynchronous outbound continuations", () => {
345
353
  "POST /invoices/{id}/issue",
346
354
  "enqueue send_invoice_email",
347
355
  "send_invoice_email",
356
+ "HTTP response",
348
357
  ]);
349
358
  expect(root.includes).toEqual(["send-invoice-email"]);
350
359
  expect(enrichCatalog(result).catalog).toEqual(result);
@@ -404,6 +413,127 @@ describe("enrichCatalog: asynchronous outbound continuations", () => {
404
413
  expect(enrichCatalog(once).catalog).toEqual(once);
405
414
  });
406
415
 
416
+ it("synthesizes returns for nested unary rpc and the HTTP root", () => {
417
+ const entrypoint = "pricing/rpc:Server.Quote";
418
+ const api = {
419
+ ...flow("quote-api", [
420
+ step("client", "shop.oms", "rpc", { label: "GET /quote" }),
421
+ step("shop.oms", "shop.pricing", "rpc", {
422
+ ref: METHOD,
423
+ label: "Quote",
424
+ continuesAt: entrypoint,
425
+ }),
426
+ ]),
427
+ trigger: {
428
+ kind: "http",
429
+ label: "GET /quote",
430
+ confidence: "high",
431
+ } as const,
432
+ };
433
+ const provider = {
434
+ ...flow("quote-rpc", [
435
+ step("shop.pricing", "shop.pricing", "call", {
436
+ label: "load quote",
437
+ }),
438
+ ]),
439
+ entrypoint,
440
+ trigger: {
441
+ kind: "unproven",
442
+ label: METHOD,
443
+ confidence: "high",
444
+ } as const,
445
+ };
446
+
447
+ const once = enrichCatalog(estate([api, provider])).catalog;
448
+ const root = once.flows.find((item) => item.slug === "quote-api")!;
449
+ const steps = walkSteps(root.steps);
450
+
451
+ expect(
452
+ steps.map((item) => [item.kind, item.from, item.to, item.label]),
453
+ ).toEqual([
454
+ ["rpc", "client", "shop.oms", "GET /quote"],
455
+ ["rpc", "shop.oms", "shop.pricing", "Quote"],
456
+ ["call", "shop.pricing", "shop.pricing", "load quote"],
457
+ ["response", "shop.pricing", "shop.oms", "QuoteResponse"],
458
+ ["response", "shop.oms", "client", "HTTP response"],
459
+ ]);
460
+ expect(steps[3]!.replyTo).toBe(steps[1]!.id);
461
+ expect(steps[4]!.replyTo).toBe(steps[0]!.id);
462
+ expect(enrichCatalog(once).catalog).toEqual(once);
463
+ });
464
+
465
+ it("hydrates a source-proven HTTP body from the serialized rpc response", () => {
466
+ const api = {
467
+ ...flow("quote-api", [
468
+ step("client", "shop.oms", "rpc", { label: "GET /quote" }),
469
+ step("shop.oms", "shop.pricing", "rpc", {
470
+ ref: METHOD,
471
+ label: "Quote",
472
+ }),
473
+ step("shop.oms", "client", "response", {
474
+ label: "200 · HTTP response",
475
+ replyTo: "s1",
476
+ http: {
477
+ status: 200,
478
+ contentType: "application/json",
479
+ bodyRef: METHOD,
480
+ encoding: "protojson",
481
+ outcome: "success",
482
+ },
483
+ }),
484
+ ]),
485
+ trigger: {
486
+ kind: "http",
487
+ label: "GET /quote",
488
+ confidence: "high",
489
+ } as const,
490
+ };
491
+
492
+ const once = enrichCatalog(estate([api])).catalog;
493
+ const response = walkSteps(once.flows[0]!.steps).at(-1)!;
494
+
495
+ expect(response.label).toBe("200 · QuoteResponse");
496
+ expect(response.http?.body).toBe("QuoteResponse");
497
+ expect(response.http?.bodyRef).toBe(METHOD);
498
+ expect(enrichCatalog(once).catalog).toEqual(once);
499
+ });
500
+
501
+ it("does not synthesize a unary return for a streaming rpc", () => {
502
+ const entrypoint = "pricing/rpc:Server.Watch";
503
+ const api = {
504
+ ...flow("watch-api", [
505
+ step("client", "shop.oms", "rpc", { label: "GET /watch" }),
506
+ step("shop.oms", "shop.pricing", "rpc", {
507
+ ref: METHOD,
508
+ label: "Quote",
509
+ continuesAt: entrypoint,
510
+ }),
511
+ ]),
512
+ trigger: {
513
+ kind: "http",
514
+ label: "GET /watch",
515
+ confidence: "high",
516
+ } as const,
517
+ };
518
+ const provider = {
519
+ ...flow("watch-rpc", [
520
+ step("shop.pricing", "shop.pricing", "call", { label: "watch" }),
521
+ ]),
522
+ entrypoint,
523
+ };
524
+ const streaming = pricing();
525
+ streaming.provides[0]!.methods[0]!.streaming = "server";
526
+
527
+ const result = enrichCatalog(
528
+ estate([api, provider], [oms(), streaming]),
529
+ ).catalog;
530
+ const responses = walkSteps(result.flows[0]!.steps).filter(
531
+ (item) => item.kind === "response",
532
+ );
533
+
534
+ expect(responses.map((item) => item.label)).toEqual(["HTTP response"]);
535
+ });
536
+
407
537
  it("refuses an ambiguous message handoff", () => {
408
538
  const publisher = flow("publisher", [
409
539
  step("shop.oms", "bus", "event", {
@@ -892,6 +1022,83 @@ describe("enrichCatalog: invariants", () => {
892
1022
  });
893
1023
  });
894
1024
 
1025
+ describe("enrichCatalog: Redis store accesses", () => {
1026
+ it("joins a repository call to one concrete Redis key access", () => {
1027
+ const c = estate([
1028
+ flow("load-order", [
1029
+ step("shop.oms", "oms-db", "call", {
1030
+ label: "Get",
1031
+ storeAccess: { store: "shop.oms.redis", method: "Get" },
1032
+ }),
1033
+ ]),
1034
+ ]);
1035
+ c.stores = [
1036
+ {
1037
+ id: "shop.oms.redis",
1038
+ slug: "redis",
1039
+ name: "OMS Redis",
1040
+ kind: "redis",
1041
+ owner: "shop.oms",
1042
+ tables: [],
1043
+ keyspaces: [
1044
+ {
1045
+ pattern: "order:{id}",
1046
+ operations: ["read"],
1047
+ accesses: [
1048
+ {
1049
+ operation: "read",
1050
+ method: "Store.Get",
1051
+ source: "repository/redis.go:20",
1052
+ },
1053
+ ],
1054
+ },
1055
+ ],
1056
+ },
1057
+ ];
1058
+
1059
+ const result = enrichCatalog(c).catalog;
1060
+ expect(walkSteps(result.flows[0]!.steps)[0]!.storeAccess).toEqual({
1061
+ store: "shop.oms.redis",
1062
+ method: "Get",
1063
+ operation: "read",
1064
+ keyspace: "order:{id}",
1065
+ source: "repository/redis.go:20",
1066
+ });
1067
+ expect(enrichCatalog(result).catalog).toBe(result);
1068
+ });
1069
+
1070
+ it("leaves an ambiguous repository method unresolved", () => {
1071
+ const c = estate([
1072
+ flow("load-order", [
1073
+ step("shop.oms", "oms-db", "call", {
1074
+ storeAccess: { store: "shop.oms.redis", method: "Get" },
1075
+ }),
1076
+ ]),
1077
+ ]);
1078
+ c.stores = [
1079
+ {
1080
+ id: "shop.oms.redis",
1081
+ slug: "redis",
1082
+ name: "OMS Redis",
1083
+ kind: "redis",
1084
+ owner: "shop.oms",
1085
+ tables: [],
1086
+ keyspaces: ["order:{id}", "order-by-number:{number}"].map(
1087
+ (pattern) => ({
1088
+ pattern,
1089
+ operations: ["read" as const],
1090
+ accesses: [{ operation: "read" as const, method: "Store.Get" }],
1091
+ }),
1092
+ ),
1093
+ },
1094
+ ];
1095
+
1096
+ expect(
1097
+ walkSteps(enrichCatalog(c).catalog.flows[0]!.steps)[0]!.storeAccess,
1098
+ ).toEqual({ store: "shop.oms.redis", method: "Get" });
1099
+ });
1100
+ });
1101
+
895
1102
  describe("enrichCatalog: the auth fragment", () => {
896
1103
  // The three fragments together, the way the app reads them: the domain one
897
1104
  // alone names endpoints the api one declares.