@shortlink-org/portolan 0.2.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (184) hide show
  1. package/README.md +23 -0
  2. package/catalog/enum_test.go +46 -0
  3. package/catalog/evidence_test.go +35 -0
  4. package/catalog/model.go +1066 -0
  5. package/catalog/roundtrip_test.go +203 -0
  6. package/catalog/via_test.go +38 -0
  7. package/cli/init.test.mjs +6 -1
  8. package/cli/portolan.mjs +8 -0
  9. package/cli/portolan.test.mjs +49 -0
  10. package/go.mod +14 -0
  11. package/go.sum +20 -0
  12. package/internal/gocall/README.md +19 -0
  13. package/internal/gocall/analyze.go +189 -0
  14. package/internal/gocall/analyze_test.go +107 -0
  15. package/internal/gohttp/analyze.go +2562 -0
  16. package/internal/gohttp/destination.go +373 -0
  17. package/internal/gohttp/endpoints.go +1067 -0
  18. package/internal/gohttp/roots.go +320 -0
  19. package/internal/gohttp/typed.go +96 -0
  20. package/internal/goscan/constants.go +85 -0
  21. package/internal/goscan/goscan_test.go +227 -0
  22. package/internal/goscan/index.go +629 -0
  23. package/internal/goscan/index_test.go +66 -0
  24. package/internal/goscan/names.go +52 -0
  25. package/internal/goscan/parse_test.go +11 -0
  26. package/internal/goscan/source.go +37 -0
  27. package/internal/goscan/tree.go +284 -0
  28. package/internal/goscan/types.go +99 -0
  29. package/internal/wsdl/ids.go +127 -0
  30. package/internal/wsdl/ids_test.go +21 -0
  31. package/internal/wsdl/model.go +70 -0
  32. package/internal/wsdl/parse.go +949 -0
  33. package/internal/wsdl/parse_test.go +170 -0
  34. package/package.json +22 -10
  35. package/plugin/describe.go +118 -0
  36. package/plugin/describe_test.go +114 -0
  37. package/plugin/protocol.go +141 -0
  38. package/plugin/schematest/schematest.go +126 -0
  39. package/plugins/README.md +114 -46
  40. package/plugins/cmd/portolan-http-clients/main.go +19 -0
  41. package/plugins/extract-celery/extract.py +0 -2
  42. package/plugins/extract-celery/extract_test.py +1 -1
  43. package/plugins/extract-django/README.md +39 -17
  44. package/plugins/extract-django/domain.py +28 -17
  45. package/plugins/extract-django/extract.py +21 -7
  46. package/plugins/extract-django/extract_test.py +55 -2
  47. package/plugins/extract-django/lifecycle.py +2 -0
  48. package/plugins/extract-django/operations.py +1 -1
  49. package/plugins/extract-django/routing_test.py +109 -1
  50. package/plugins/extract-django/store.py +1 -1
  51. package/plugins/extract-django/transport.py +101 -55
  52. package/plugins/extract-django/verbs.py +241 -0
  53. package/plugins/extract-go/README.md +47 -0
  54. package/plugins/extract-http-clients/describe.go +19 -0
  55. package/plugins/extract-http-clients/describe_test.go +11 -0
  56. package/plugins/extract-http-clients/extract.go +740 -0
  57. package/plugins/extract-http-clients/extract_test.go +1561 -0
  58. package/plugins/extract-http-clients/main.go +41 -0
  59. package/plugins/extract-java/build/org/portolan/extract/Extract.class +0 -0
  60. package/plugins/extract-java/build/org/portolan/extract/Protocol$Builder.class +0 -0
  61. package/plugins/extract-java/build/org/portolan/extract/Protocol$Input.class +0 -0
  62. package/plugins/extract-java/build/org/portolan/extract/Protocol$Options.class +0 -0
  63. package/plugins/extract-python-kafka/README.md +6 -0
  64. package/plugins/extract-python-kafka/extract.py +2 -4
  65. package/plugins/extract-python-kafka/extract_test.py +18 -2
  66. package/plugins/extract-ts/extract.test.ts +2 -2
  67. package/plugins/extract-ts/extract.ts +4 -5
  68. package/plugins/extract-ts/graphql.test.ts +1 -1
  69. package/plugins/openapi/ids.go +261 -0
  70. package/plugins/openapi/ids_test.go +98 -0
  71. package/plugins/portolan-go.wasm +0 -0
  72. package/plugins/pyplugin/catalog.py +12 -1
  73. package/plugins/pyplugin/kafka.py +74 -3
  74. package/plugins/pyplugin/protocol.py +1 -5
  75. package/portolan.json +3 -2
  76. package/schema/portolan.schema.json +34 -0
  77. package/scripts/README.md +18 -12
  78. package/scripts/catalog-sources.mjs +6 -0
  79. package/scripts/delivery-presets.mjs +21 -11
  80. package/scripts/diff.mjs +5 -1
  81. package/scripts/django-aggregates.test.mjs +58 -0
  82. package/scripts/gen-likec4.mjs +79 -21
  83. package/scripts/gen-likec4.test.mjs +25 -2
  84. package/scripts/gen.mjs +118 -115
  85. package/scripts/go-discovery.test.mjs +30 -0
  86. package/scripts/history.mjs +186 -3
  87. package/scripts/history.test.mjs +1 -1
  88. package/scripts/host-plugins/fetch-git.mjs +77 -21
  89. package/scripts/host-plugins/fetch-git.test.mjs +62 -8
  90. package/scripts/local-api.mjs +71 -4
  91. package/scripts/local-api.test.mjs +63 -4
  92. package/scripts/local-discovery.mjs +82 -9
  93. package/scripts/manifest.mjs +5 -3
  94. package/scripts/manifest.test.mjs +24 -0
  95. package/scripts/output-diff.mjs +94 -0
  96. package/scripts/output-diff.test.mjs +36 -0
  97. package/scripts/package-smoke.mjs +62 -4
  98. package/scripts/plugin-host.mjs +22 -2
  99. package/scripts/plugin-host.test.mjs +9 -0
  100. package/scripts/plugin-wasm-worker.mjs +4 -1
  101. package/scripts/provenance.mjs +72 -0
  102. package/scripts/provenance.test.mjs +149 -0
  103. package/scripts/run-builtin.mjs +39 -5
  104. package/scripts/schema.mjs +29 -0
  105. package/scripts/warning-policy.mjs +161 -0
  106. package/scripts/warning-policy.test.mjs +56 -0
  107. package/src/app/Breadcrumbs.test.ts +4 -0
  108. package/src/app/Breadcrumbs.tsx +1 -0
  109. package/src/app/Sidebar.tsx +3 -3
  110. package/src/catalog-docs.test.ts +64 -0
  111. package/src/catalog-docs.ts +35 -0
  112. package/src/catalog-error.test.ts +15 -0
  113. package/src/catalog-model.ts +70 -6
  114. package/src/catalog-stores.test.ts +17 -0
  115. package/src/catalog-validation.ts +52 -2
  116. package/src/catalog.test.ts +13 -2
  117. package/src/chat/Starter.tsx +5 -11
  118. package/src/chat/tools.test.ts +27 -0
  119. package/src/chat/tools.ts +5 -9
  120. package/src/components/CatalogStamp.tsx +10 -8
  121. package/src/components/ChannelRows.messagepack.test.tsx +28 -0
  122. package/src/components/ChannelRows.test.tsx +54 -0
  123. package/src/components/ChannelRows.tsx +57 -10
  124. package/src/components/HTTPDestinationEvidence.test.tsx +23 -0
  125. package/src/components/HTTPDestinationEvidence.tsx +31 -0
  126. package/src/components/Integrations.tsx +1 -1
  127. package/src/components/LifecycleDiagram.tsx +28 -12
  128. package/src/components/MachineDocs.tsx +6 -5
  129. package/src/components/MethodRows.tsx +9 -2
  130. package/src/components/ProblemRow.tsx +4 -0
  131. package/src/components/RelationEvidence.test.tsx +14 -0
  132. package/src/components/RelationEvidence.tsx +53 -0
  133. package/src/components/WhatLinksHere.tsx +6 -4
  134. package/src/data.ts +25 -7
  135. package/src/enrich.test.ts +336 -6
  136. package/src/enrich.ts +206 -3
  137. package/src/flow/StepDetail.tsx +104 -54
  138. package/src/flow/answers.test.ts +18 -1
  139. package/src/flow/answers.ts +37 -8
  140. package/src/flow/evidence.test.ts +16 -0
  141. package/src/flow/evidence.ts +34 -0
  142. package/src/index.css +44 -0
  143. package/src/landing/DraggableReveal.tsx +3 -2
  144. package/src/landing/EvidencePipeline.tsx +105 -0
  145. package/src/landing/LandingPage.tsx +2 -59
  146. package/src/lib/backlinks.test.ts +16 -1
  147. package/src/lib/backlinks.ts +20 -0
  148. package/src/lib/catalog-diff.test.ts +18 -0
  149. package/src/lib/catalog-diff.ts +20 -2
  150. package/src/lib/derive.ts +1 -0
  151. package/src/lib/django-aggregates.d.mts +9 -0
  152. package/src/lib/django-aggregates.mjs +36 -0
  153. package/src/lib/django-aggregates.test.ts +29 -0
  154. package/src/lib/django-aggregates.ts +5 -0
  155. package/src/lib/kafka-ui.test.ts +87 -0
  156. package/src/lib/kafka-ui.ts +105 -0
  157. package/src/lib/local-api.ts +20 -2
  158. package/src/lib/setup-info.test.ts +17 -0
  159. package/src/lib/setup-info.ts +58 -0
  160. package/src/lib/warnings.test.ts +54 -0
  161. package/src/lib/warnings.ts +260 -0
  162. package/src/lib/wire-problems.test.ts +21 -0
  163. package/src/lib/wire-problems.ts +62 -1
  164. package/src/likec4/FlowView.tsx +2 -6
  165. package/src/likec4/flow-edges.test.ts +64 -1
  166. package/src/likec4/flow-edges.ts +43 -7
  167. package/src/likec4/view-index.ts +8 -2
  168. package/src/map/ContextMapGraph.tsx +76 -32
  169. package/src/merge.test.ts +23 -0
  170. package/src/merge.ts +33 -10
  171. package/src/pages/AggregatePage.tsx +8 -7
  172. package/src/pages/CatalogFailure.tsx +2 -2
  173. package/src/pages/ContextPage.tsx +6 -5
  174. package/src/pages/ServicePage.tsx +4 -3
  175. package/src/pages/Settings.tsx +200 -44
  176. package/src/pages/settings/DjangoAggregateChoices.tsx +79 -0
  177. package/src/pages/settings/IntegrationsSettings.tsx +117 -0
  178. package/src/routes.test.ts +2 -0
  179. package/src/routes.ts +2 -1
  180. package/src/selection/DetailPanel.tsx +61 -1
  181. package/src/virtual-provenance.d.ts +11 -0
  182. package/vite.config.ts +5 -0
  183. package/scripts/vendor-lock.mjs +0 -58
  184. package/scripts/vendor-lock.test.mjs +0 -69
@@ -1,18 +1,20 @@
1
+ import { RelationEvidencePanel } from "../components/RelationEvidence";
2
+ import { stepRelationEvidence } from "./evidence";
3
+ import { HTTPDestinationEvidence } from "../components/HTTPDestinationEvidence";
1
4
  import { useMemo } from "react";
2
5
  import { Link } from "react-router";
3
- import { AlertTriangle } from "lucide-react";
6
+ import { AlertTriangle, FileCode2 } from "lucide-react";
4
7
  import { allRepos, stepFrames } from "../catalog";
5
8
  import type { Flow, Step, StepFrame } from "../catalog";
6
9
  import { catalog, index } from "../data";
7
10
  import { Ident } from "../components/Ident";
8
- import { EditorLink } from "../components/EditorLink";
9
- import { SourcePreviewButton } from "../components/SourcePreview";
11
+ import { SourcePreviewLink } from "../components/SourcePreview";
10
12
  import { flowRepoService } from "../lib/derive";
11
13
  import { sourceLocation } from "../lib/source-link";
12
14
  import { AdrNumber, StatusChip } from "../components/primitives";
13
15
  import { ShapeRows } from "../components/ShapeRows";
14
16
  import { shapeFor } from "../components/MethodRows";
15
- import { stepAnswer } from "./answers";
17
+ import { stepAnswer, stepRpcContract } from "./answers";
16
18
  import {
17
19
  aggregatePath,
18
20
  paths,
@@ -154,12 +156,28 @@ function EventDetail({ step, flow }: { step: Step; flow: Flow }) {
154
156
  }
155
157
 
156
158
  function RpcDetail({ step, flow }: { step: Step; flow: Flow }) {
157
- const method = step.ref ?? step.label ?? "(unknown method)";
159
+ const contract = stepRpcContract(index, step);
160
+ const method = contract?.id ?? step.ref ?? step.label ?? "(unknown method)";
158
161
  const call = step.ref ? index.rpcById.get(step.ref) : undefined;
159
- const provider = step.ref
160
- ? index.rpcProviderByMethod.get(step.ref)
162
+ const provider = contract?.provider;
163
+ const internalProvider = provider
164
+ ? index.serviceById.get(provider.id)
161
165
  : undefined;
162
- const providerPath = provider ? servicePath(provider.id) : null;
166
+ const providerPath = internalProvider ? servicePath(internalProvider.id) : null;
167
+ const requestFields = contract
168
+ ? shapeFor(
169
+ contract.provided,
170
+ contract.method.request,
171
+ contract.method.requestRef,
172
+ )
173
+ : null;
174
+ const responseFields = contract
175
+ ? shapeFor(
176
+ contract.provided,
177
+ contract.method.response,
178
+ contract.method.responseRef,
179
+ )
180
+ : null;
163
181
  // The flow records the hop; what comes back is the contract's to say.
164
182
  const answer = stepAnswer(index, step);
165
183
 
@@ -175,6 +193,8 @@ function RpcDetail({ step, flow }: { step: Step; flow: Flow }) {
175
193
  </div>
176
194
  </header>
177
195
 
196
+ {step.destination ? <HTTPDestinationEvidence destination={step.destination} /> : null}
197
+
178
198
  <DetailSection title="Contract">
179
199
  <dl className="grid grid-cols-[minmax(7rem,auto)_minmax(0,1fr)] gap-x-4 gap-y-2">
180
200
  {answer ? (
@@ -185,10 +205,12 @@ function RpcDetail({ step, flow }: { step: Step; flow: Flow }) {
185
205
  ) : null}
186
206
  <dt className="mono text-muted">Provider</dt>
187
207
  <dd>
188
- {provider && providerPath ? (
208
+ {internalProvider && providerPath ? (
189
209
  <Link to={providerPath} className="mono text-accent">
190
- {provider.id} →
210
+ {internalProvider.id} →
191
211
  </Link>
212
+ ) : provider ? (
213
+ <span className="mono text-ink">{provider.id}</span>
192
214
  ) : (
193
215
  <span className="mono inline-flex items-center gap-1.5 rounded-control border px-1.5 py-0.5 status-unresolved">
194
216
  <AlertTriangle size={11} aria-hidden />
@@ -205,6 +227,34 @@ function RpcDetail({ step, flow }: { step: Step; flow: Flow }) {
205
227
  </dl>
206
228
  </DetailSection>
207
229
 
230
+ {contract?.method.request ? (
231
+ <DetailSection title="Request" meta={contract.method.request}>
232
+ {requestFields ? (
233
+ <ShapeRows
234
+ fields={requestFields}
235
+ enums={contract.provided.enums}
236
+ showHeader
237
+ />
238
+ ) : (
239
+ <div className="mono text-muted">shape not recorded</div>
240
+ )}
241
+ </DetailSection>
242
+ ) : null}
243
+
244
+ {contract?.method.response ? (
245
+ <DetailSection title="Response" meta={contract.method.response}>
246
+ {responseFields ? (
247
+ <ShapeRows
248
+ fields={responseFields}
249
+ enums={contract.provided.enums}
250
+ showHeader
251
+ />
252
+ ) : (
253
+ <div className="mono text-muted">shape not recorded</div>
254
+ )}
255
+ </DetailSection>
256
+ ) : null}
257
+
208
258
  <DetailSection title="Source">
209
259
  {call ? (
210
260
  <SourceWhere where={call.source} flow={flow} structured />
@@ -525,48 +575,25 @@ function SourceWhere({
525
575
  allRepos(catalog),
526
576
  );
527
577
 
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
- </>
578
+ const source = (
579
+ <SourcePreviewLink
580
+ location={location}
581
+ className={
582
+ structured
583
+ ? "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"
584
+ : "max-w-full min-w-0 text-muted hover:text-accent"
585
+ }
586
+ >
587
+ <FileCode2 size={14} aria-hidden className="shrink-0" />
588
+ <span className="min-w-0 break-all text-left">{where}</span>
589
+ </SourcePreviewLink>
551
590
  );
552
591
 
553
592
  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
- );
593
+ return <div className="min-w-0">{source}</div>;
562
594
  }
563
595
 
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
- );
596
+ return <div className="min-w-0">{source}</div>;
570
597
  }
571
598
 
572
599
  function Where({ step, flow }: { step: Step; flow: Flow }) {
@@ -574,6 +601,34 @@ function Where({ step, flow }: { step: Step; flow: Flow }) {
574
601
  return <SourceWhere where={step.line} flow={flow} />;
575
602
  }
576
603
 
604
+ function CallDetail({ step, flow }: { step: Step; flow: Flow }) {
605
+ return (
606
+ <section
607
+ aria-label="Call detail"
608
+ className="overflow-hidden rounded-card border shadow-xs border-line"
609
+ >
610
+ <header className="border-b px-3 py-2.5 border-line bg-surface">
611
+ <h2 className="label">Call</h2>
612
+ <div className="mt-2">
613
+ <Ident
614
+ block
615
+ value={step.label ?? "internal call"}
616
+ className="text-ink"
617
+ />
618
+ </div>
619
+ </header>
620
+
621
+ <DetailSection title="Source">
622
+ {step.line ? (
623
+ <SourceWhere where={step.line} flow={flow} structured />
624
+ ) : (
625
+ <div className="mono text-muted">not recorded</div>
626
+ )}
627
+ </DetailSection>
628
+ </section>
629
+ );
630
+ }
631
+
577
632
  function StoreCallDetail({ step, flow }: { step: Step; flow: Flow }) {
578
633
  const access = step.storeAccess!;
579
634
  const store = index.storeById.get(access.store);
@@ -668,6 +723,7 @@ export function StepDetailBody({ step, flow }: { step: Step; flow: Flow }) {
668
723
  return (
669
724
  <>
670
725
  <ExecutionContext step={step} flow={flow} />
726
+ <RelationEvidencePanel items={stepRelationEvidence(index, step)} renderSource={(where) => <SourceWhere where={where} flow={flow} structured />} />
671
727
 
672
728
  {/* A decision that names this step's event is the reason the step
673
729
  looks the way it does. It belongs next to the step, not three
@@ -699,13 +755,7 @@ export function StepDetailBody({ step, flow }: { step: Step; flow: Flow }) {
699
755
  ) : step.storeAccess ? (
700
756
  <StoreCallDetail step={step} flow={flow} />
701
757
  ) : (
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
- </>
758
+ <CallDetail step={step} flow={flow} />
709
759
  )}
710
760
 
711
761
  {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.
@@ -9,14 +9,33 @@
9
9
  // queryset - which no interface in the catalog describes; an event is a
10
10
  // publication, and drawing a reply to it would be a lie about the bus.
11
11
 
12
- import type { CatalogIndex, Flow, RpcMethod, Step } from "../catalog";
12
+ import type {
13
+ CatalogIndex,
14
+ External,
15
+ Flow,
16
+ RpcMethod,
17
+ RpcService,
18
+ Service,
19
+ Step,
20
+ } from "../catalog";
13
21
  import { walkSteps } from "../catalog";
14
22
 
15
- /** The method a step reaches, when the catalog has it. */
16
- function methodOf(index: CatalogIndex, step: Step): RpcMethod | undefined {
23
+ export interface StepRpcContract {
24
+ id: string;
25
+ provider: Service | External;
26
+ provided: RpcService;
27
+ method: RpcMethod;
28
+ }
29
+
30
+ /** The interface and method an RPC step reaches, when the catalog has them. */
31
+ export function stepRpcContract(
32
+ index: CatalogIndex,
33
+ step: Step,
34
+ ): StepRpcContract | undefined {
17
35
  if (step.kind !== "rpc") return undefined;
18
36
 
19
- // Outgoing: the step names the call, and the call id is `<interface>/<method>`.
37
+ // A recorded ref is the full `<interface>/<method>` id, whether the flow
38
+ // enters this service or calls another one.
20
39
  if (step.ref) {
21
40
  const cut = step.ref.lastIndexOf("/");
22
41
  if (cut < 0) return undefined;
@@ -26,22 +45,32 @@ function methodOf(index: CatalogIndex, step: Step): RpcMethod | undefined {
26
45
  const provider =
27
46
  index.rpcProviderByMethod.get(step.ref) ?? index.externalProviderByMethod.get(step.ref);
28
47
  const provided = provider?.provides.find((p) => p.id === interfaceId);
29
- return provided?.methods.find((m) => m.name === name);
48
+ const method = provided?.methods.find((m) => m.name === name);
49
+ if (!provider || !provided || !method) return undefined;
50
+ return { id: step.ref, provider, provided, method };
30
51
  }
31
52
 
32
- // Incoming: somebody called this service, and the label is the operation.
53
+ // Older incoming flows recorded only the operation label. Keep resolving
54
+ // those catalogs while new extractors write the full ref above.
33
55
  const service = index.serviceById.get(step.to);
34
56
  if (!service || !step.label) return undefined;
35
57
  for (const provided of service.provides) {
36
58
  const found = provided.methods.find((m) => m.name === step.label);
37
- if (found) return found;
59
+ if (found) {
60
+ return {
61
+ id: `${provided.id}/${found.name}`,
62
+ provider: service,
63
+ provided,
64
+ method: found,
65
+ };
66
+ }
38
67
  }
39
68
  return undefined;
40
69
  }
41
70
 
42
71
  /** What the callee hands back, as the contract names it. */
43
72
  export function stepAnswer(index: CatalogIndex, step: Step): string | undefined {
44
- return methodOf(index, step)?.response || undefined;
73
+ return stepRpcContract(index, step)?.method.response || undefined;
45
74
  }
46
75
 
47
76
  /** Every request without an explicit response step that has an answer. */
@@ -0,0 +1,16 @@
1
+ import { expect, it } from "vitest";
2
+ import { buildIndex, type Catalog, type Step } from "../catalog";
3
+ import { stepRelationEvidence } from "./evidence";
4
+
5
+ const index = buildIndex({ generatedAt: "", commit: "", contexts: [], defs: {}, flows: [], adrs: [] } as Catalog);
6
+ it("gives legacy call, event, RPC and response steps an honest source explanation", () => {
7
+ for (const kind of ["call", "event", "rpc", "response"] as const) {
8
+ const step: Step = { type: "step", id: "s1", from: "a", to: "b", kind, status: "declared", line: "handler.go:8" };
9
+ expect(stepRelationEvidence(index, step)).toEqual([{ kind: "call-site", rule: "source-expression", source: "handler.go:8", symbol: undefined }]);
10
+ }
11
+ });
12
+ it("keeps unresolved status and deduplicates recorded evidence", () => {
13
+ const evidence = { kind: "call-site" as const, rule: "source-expression", source: "handler.go:8" };
14
+ const step: Step = { type: "step", id: "s1", from: "a", to: "b", kind: "call", status: "unresolved", line: evidence.source, evidence: [evidence, evidence] };
15
+ expect(stepRelationEvidence(index, step).map((item) => item.kind)).toEqual(["call-site", "unresolved"]);
16
+ });
@@ -0,0 +1,34 @@
1
+ import type { CatalogIndex, RelationEvidence, Step } from "../catalog";
2
+ import { stepRpcContract } from "./answers";
3
+
4
+ /** Adapt older fragments to the same explanation without inventing a path. */
5
+ export function stepRelationEvidence(index: CatalogIndex, step: Step): RelationEvidence[] {
6
+ const items = [...(step.evidence ?? [])];
7
+ if (step.line && !items.some((item) => item.kind === "call-site" && item.source === step.line)) {
8
+ items.push({ kind: "call-site", rule: "source-expression", source: step.line, symbol: step.label });
9
+ }
10
+ const contract = stepRpcContract(index, step);
11
+ if (contract) items.push({ kind: "contract", rule: "catalog-contract", source: contract.provided.source, symbol: contract.id });
12
+ if (step.kind === "event" && step.ref) {
13
+ const event = index.eventById.get(step.ref);
14
+ const version = event?.versions.at(-1);
15
+ if (version) items.push({ kind: "contract", rule: "event-declaration", source: version.source, symbol: step.ref });
16
+ }
17
+ if (step.storeAccess?.source) items.push({ kind: "binding", rule: "repository-sql-call", source: step.storeAccess.source, symbol: step.storeAccess.method });
18
+ if (step.destination?.resolution) {
19
+ const resolution = step.destination.resolution;
20
+ items.push({ kind: "resolution", rule: resolution.basis, symbol: `${resolution.provider} ${resolution.route}` });
21
+ }
22
+ if (step.continuesAt) items.push({ kind: "resolution", rule: "continuation-entrypoint", symbol: step.continuesAt });
23
+ if (step.kind === "response" && step.replyTo) items.push({ kind: "resolution", rule: "response-to-request", symbol: step.replyTo });
24
+ if (step.status === "unresolved" && !items.some((item) => item.kind === "unresolved")) {
25
+ items.push({ kind: "unresolved", rule: "unresolved-target", symbol: step.ref ?? step.label });
26
+ }
27
+ const seen = new Set<string>();
28
+ return items.filter((item) => {
29
+ const key = JSON.stringify(item);
30
+ if (seen.has(key)) return false;
31
+ seen.add(key);
32
+ return true;
33
+ });
34
+ }
package/src/index.css CHANGED
@@ -681,6 +681,50 @@
681
681
  outline: 2px solid color-mix(in srgb, var(--accent) 55%, white);
682
682
  outline-offset: 3px;
683
683
  }
684
+ .product-danger {
685
+ color: white;
686
+ border-color: #a33a4b;
687
+ background:
688
+ radial-gradient(
689
+ circle at 18% 0%,
690
+ rgb(255 255 255 / 0.16),
691
+ transparent 42%
692
+ ),
693
+ linear-gradient(
694
+ to bottom,
695
+ #8e2b3d,
696
+ #651827
697
+ );
698
+ box-shadow:
699
+ inset 0 1px 0 rgb(255 255 255 / 0.2),
700
+ inset 0 -1px 0 rgb(0 0 0 / 0.28),
701
+ 0 1px 2px rgb(0 0 0 / 0.24),
702
+ 0 12px 28px -18px color-mix(in srgb, var(--status-unresolved) 84%, transparent);
703
+ text-shadow: 0 1px 0 rgb(0 0 0 / 0.32);
704
+ }
705
+ .product-danger:hover {
706
+ color: white;
707
+ border-color: #c45a6b;
708
+ background:
709
+ radial-gradient(
710
+ circle at 18% 0%,
711
+ rgb(255 255 255 / 0.2),
712
+ transparent 42%
713
+ ),
714
+ linear-gradient(
715
+ to bottom,
716
+ #a13246,
717
+ #741b2c
718
+ );
719
+ box-shadow:
720
+ inset 0 1px 0 rgb(255 255 255 / 0.26),
721
+ inset 0 -1px 0 rgb(0 0 0 / 0.3),
722
+ 0 2px 4px rgb(0 0 0 / 0.25),
723
+ 0 16px 34px -17px color-mix(in srgb, var(--status-unresolved) 96%, transparent);
724
+ }
725
+ .product-danger:focus-visible {
726
+ outline-color: color-mix(in srgb, var(--status-unresolved) 62%, white);
727
+ }
684
728
  .product-primary:disabled,
685
729
  .tbtn:disabled {
686
730
  cursor: not-allowed;
@@ -105,7 +105,9 @@ export function DraggableReveal({
105
105
  className={`pointer-events-none absolute inset-0 z-0 flex ${catPlacement[revealEdge]}`}
106
106
  >
107
107
  <m.img
108
- layout="position"
108
+ // Deliberately not a layout animation: the preview's children can
109
+ // reflow independently, and their tab changes must not send this
110
+ // hidden illustration across the background.
109
111
  src={cat}
110
112
  alt=""
111
113
  width={800}
@@ -118,7 +120,6 @@ export function DraggableReveal({
118
120
  y: dragging ? -3 : 0,
119
121
  }}
120
122
  transition={{
121
- layout: { type: "spring", stiffness: 260, damping: 28 },
122
123
  opacity: { duration: 0.22 },
123
124
  scale: { duration: 0.28 },
124
125
  y: { duration: 0.28 },
@@ -0,0 +1,105 @@
1
+ import { ArrowRight, Braces, CircleDotDashed, Code2, Network } from "lucide-react";
2
+ import { useRef } from "react";
3
+ import { useInView } from "motion/react";
4
+ import { m, useReducedMotion } from "../lib/motion";
5
+
6
+ const steps = [
7
+ {
8
+ icon: Code2,
9
+ label: "01 / read",
10
+ title: "Code and specifications",
11
+ copy: "Existing source, schemas, contracts, ADRs and traces.",
12
+ },
13
+ {
14
+ icon: Braces,
15
+ label: "02 / extract",
16
+ title: "Local fragments",
17
+ copy: "Each service publishes the architectural facts it owns.",
18
+ },
19
+ {
20
+ icon: CircleDotDashed,
21
+ label: "03 / validate",
22
+ title: "One estate model",
23
+ copy: "Portolan merges the union and checks every relationship.",
24
+ },
25
+ {
26
+ icon: Network,
27
+ label: "04 / publish",
28
+ title: "A static catalog",
29
+ copy: "Browse the site or export Markdown, Mermaid and Backstage entities.",
30
+ },
31
+ ] as const;
32
+
33
+ // One shared cycle keeps the arrows in sync, including the quiet pause.
34
+ const cycle = 6.8;
35
+ const pulse = (index: number) => {
36
+ const start = 0.4 + index * 0.85;
37
+ return {
38
+ duration: cycle,
39
+ repeat: Infinity,
40
+ ease: "easeInOut" as const,
41
+ times: [0, start, start + 0.2, start + 0.65, start + 1.15, cycle].map(
42
+ (time) => time / cycle,
43
+ ),
44
+ };
45
+ };
46
+
47
+ export function EvidencePipeline() {
48
+ const ref = useRef<HTMLOListElement>(null);
49
+ const inView = useInView(ref, { amount: 0.2 });
50
+ const reduced = useReducedMotion();
51
+ const playing = inView && !reduced;
52
+
53
+ return (
54
+ <ol
55
+ ref={ref}
56
+ aria-label="How Portolan turns evidence into architecture documentation"
57
+ className="mt-12 grid gap-4 md:grid-cols-2 xl:grid-cols-4"
58
+ >
59
+ {steps.map((item, index) => {
60
+ const Icon = item.icon;
61
+ const continues = index < steps.length - 1;
62
+ const connectsWithinRow = index === 0 || index === 2;
63
+
64
+ return (
65
+ <li
66
+ key={item.title}
67
+ className="relative rounded-card border border-line bg-canvas p-5 shadow-xs"
68
+ >
69
+ <div className="flex items-center justify-between">
70
+ <span className="flow-tile text-accent">
71
+ <Icon size={14} aria-hidden />
72
+ </span>
73
+ <span className="mono text-faint">{item.label}</span>
74
+ </div>
75
+
76
+ <h3 className="mt-8 text-md font-semibold">{item.title}</h3>
77
+ <p className="mt-2 text-sm text-muted">{item.copy}</p>
78
+
79
+ {continues ? (
80
+ <span
81
+ aria-hidden
82
+ className={`absolute top-[22px] -right-[17px] z-10 h-[18px] w-4 items-center justify-center bg-canvas text-faint ${
83
+ connectsWithinRow ? "hidden md:flex" : "hidden xl:flex"
84
+ }`}
85
+ >
86
+ <ArrowRight size={11} />
87
+ <m.span
88
+ className="absolute inset-0 flex items-center justify-center text-accent drop-shadow-[0_0_4px_var(--color-accent)]"
89
+ initial={{ opacity: 0, x: -4 }}
90
+ animate={playing ? {
91
+ opacity: [0, 0, 1, 1, 0, 0],
92
+ x: [-4, -4, -2, 2, 4, 4],
93
+ } : { opacity: 0, x: -4 }}
94
+ transition={playing ? pulse(index + 0.65) : { duration: 0.2 }}
95
+ >
96
+ <ArrowRight size={11} />
97
+ </m.span>
98
+ </span>
99
+ ) : null}
100
+ </li>
101
+ );
102
+ })}
103
+ </ol>
104
+ );
105
+ }
@@ -1,17 +1,13 @@
1
1
  import {
2
2
  ArrowRight,
3
3
  Bot,
4
- Braces,
5
4
  Check,
6
- CircleDotDashed,
7
- Code2,
8
5
  Copy,
9
6
  FileSearch,
10
7
  Files,
11
8
  FileText,
12
9
  GitBranch,
13
10
  Moon,
14
- Network,
15
11
  ShieldCheck,
16
12
  Sun,
17
13
  Terminal,
@@ -27,6 +23,7 @@ import { m } from "../lib/motion";
27
23
  import { paths } from "../routes";
28
24
  import { catalogTo } from "./catalog";
29
25
  import { DraggableReveal } from "./DraggableReveal";
26
+ import { EvidencePipeline } from "./EvidencePipeline";
30
27
  import { heroColumn, heroLine, Reveal } from "./motion";
31
28
  import { ProductFrame } from "./ProductFrame";
32
29
  import { ProductTour } from "./ProductTour";
@@ -249,33 +246,6 @@ function CopyCommand({
249
246
  );
250
247
  }
251
248
 
252
- const pipeline = [
253
- {
254
- icon: Code2,
255
- label: "01 / read",
256
- title: "Code and specifications",
257
- copy: "Existing source, schemas, contracts, ADRs and traces.",
258
- },
259
- {
260
- icon: Braces,
261
- label: "02 / extract",
262
- title: "Local fragments",
263
- copy: "Each service publishes the architectural facts it owns.",
264
- },
265
- {
266
- icon: CircleDotDashed,
267
- label: "03 / validate",
268
- title: "One estate model",
269
- copy: "Portolan merges the union and checks every relationship.",
270
- },
271
- {
272
- icon: Network,
273
- label: "04 / publish",
274
- title: "A static catalog",
275
- copy: "Browse the site or export Markdown, Mermaid and Backstage entities.",
276
- },
277
- ];
278
-
279
249
  export function LandingPage() {
280
250
  useDocumentTitle("Architecture from code");
281
251
 
@@ -422,34 +392,7 @@ export function LandingPage() {
422
392
  Architecture documentation that begins with evidence.
423
393
  </h2>
424
394
  </Reveal>
425
- <div className="mt-12 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
426
- {pipeline.map((item, index) => {
427
- const Icon = item.icon;
428
- return (
429
- <Reveal
430
- key={item.title}
431
- delay={index * 0.07}
432
- className="relative rounded-card border border-line bg-canvas p-5 shadow-xs"
433
- >
434
- <div className="flex items-center justify-between">
435
- <span className="flow-tile text-accent">
436
- <Icon size={14} />
437
- </span>
438
- <span className="mono text-faint">{item.label}</span>
439
- </div>
440
- <h3 className="mt-8 text-md font-semibold">{item.title}</h3>
441
- <p className="mt-2 text-sm text-muted">{item.copy}</p>
442
- {index < pipeline.length - 1 ? (
443
- <ArrowRight
444
- size={16}
445
- className="absolute top-1/2 -right-[11px] z-10 hidden -translate-y-1/2 rounded-full bg-canvas text-faint xl:block"
446
- aria-hidden
447
- />
448
- ) : null}
449
- </Reveal>
450
- );
451
- })}
452
- </div>
395
+ <EvidencePipeline />
453
396
  </div>
454
397
  </section>
455
398