@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
@@ -0,0 +1,23 @@
1
+ import { renderToStaticMarkup } from "react-dom/server";
2
+ import { expect, it } from "vitest";
3
+ import { HTTPDestinationEvidence } from "./HTTPDestinationEvidence";
4
+
5
+ it("shows the join and the config default behind a resolved destination", () => {
6
+ const html = renderToStaticMarkup(<HTTPDestinationEvidence destination={{
7
+ callSite: "client.go:10", endpointExpression: "c.baseURL + path", method: "POST",
8
+ localPath: "/get-admin-settings", fullPath: "/settings/get-admin-settings",
9
+ baseURL: { expression: "cfg.SettingAddr", configField: "Config.SettingAddr", environmentVariable: "SETTINGS_ADDR", kind: "config-default", value: "http://localhost:8000/settings", source: "config.go:84", optionSource: "modules_tree.go:212" },
10
+ join: { expression: "c.baseURL + path", source: "client.go:10" },
11
+ resolution: { basis: "full-path", provider: "avia.aviaadmin", route: "/settings/get-admin-settings" },
12
+ }} />);
13
+ for (const value of ["Recovered full path", "Config default (runtime may override)", "SETTINGS_ADDR", "modules_tree.go:212", "Config.SettingAddr", "/settings/get-admin-settings", "avia.aviaadmin"]) expect(html).toContain(value);
14
+ expect(html).not.toContain("heuristic");
15
+ });
16
+
17
+ it("labels suffix resolution as a heuristic", () => {
18
+ const html = renderToStaticMarkup(<HTTPDestinationEvidence destination={{
19
+ callSite: "client.go:10", endpointExpression: "/settings", method: "POST",
20
+ resolution: { basis: "unique-suffix", provider: "admin", route: "/admin/settings" },
21
+ }} />);
22
+ expect(html).toContain("Unique route suffix (heuristic)");
23
+ });
@@ -0,0 +1,31 @@
1
+ import type { HTTPDestination } from "../catalog";
2
+
3
+ /** Display the facts for this invocation, including the limits of defaults. */
4
+ export function HTTPDestinationEvidence({ destination: d }: { destination: HTTPDestination }) {
5
+ const basis = d.resolution?.basis;
6
+ const rows = [
7
+ ["Linked by", basis === "full-path" ? "Recovered full path" : basis === "exact-route" ? "Exact route" : basis === "unique-suffix" ? "Unique route suffix (heuristic)" : "Unresolved"],
8
+ ["Call site", d.callSite],
9
+ ["Endpoint expression", d.endpointExpression],
10
+ ["Local path", d.localPath],
11
+ ["Base URL expression", d.baseURL?.expression],
12
+ ["Config field", d.baseURL?.configField],
13
+ ["Environment variable", d.baseURL?.environmentVariable],
14
+ [d.baseURL?.kind === "config-default" ? "Config default (runtime may override)" : "Base URL", d.baseURL?.value],
15
+ ["Config source", d.baseURL?.source],
16
+ ["Option call site", d.baseURL?.optionSource],
17
+ ["Service discovery alias", d.serviceDiscoveryAlias],
18
+ ["Join expression", d.join?.expression],
19
+ ["Join source", d.join?.source],
20
+ ["Full path", d.fullPath],
21
+ ...(d.transforms ?? []).map((transform) => ["Runtime URL modifier (not evaluated)", `${transform.expression} · ${transform.source}`]),
22
+ ["Provider", d.resolution?.provider],
23
+ ["Provider route", d.resolution?.route],
24
+ ].filter(([, value]) => value);
25
+ return <section aria-label="HTTP destination evidence" className="border-t border-line px-3 py-3">
26
+ <h3 className="label mb-2">HTTP destination evidence</h3>
27
+ <dl className="grid min-w-0 grid-cols-1 gap-y-3">
28
+ {rows.map(([label, value], index) => <div key={`${label}-${index}`} className="min-w-0"><dt className="text-muted">{label}</dt><dd className="mono break-all text-ink">{value}</dd></div>)}
29
+ </dl>
30
+ </section>;
31
+ }
@@ -210,7 +210,7 @@ export function Integrations({ groups }: { groups: IntegrationGroup[] }) {
210
210
  <RowActions copy={call.id} label={call.id} />
211
211
  {method?.http ? (
212
212
  <p className="mono w-full text-muted">
213
- {method.http.method} {method.http.path}
213
+ {method.http.method ? `${method.http.method} ${method.http.path}` : method.http.path}
214
214
  </p>
215
215
  ) : null}
216
216
  {call.note ? (
@@ -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}
@@ -1,5 +1,5 @@
1
1
  import { FileText } from "lucide-react";
2
- import { activeCatalogProfile } from "../data";
2
+ import { activeCatalogDocs, activeCatalogProfile } from "../data";
3
3
 
4
4
  // The site as a language model reads it. The build (scripts/site-docs.mjs)
5
5
  // places the generated markdown under docs/ and llms.txt at the root, in the
@@ -10,11 +10,12 @@ import { activeCatalogProfile } from "../data";
10
10
  const base = import.meta.env.BASE_URL;
11
11
 
12
12
  const suffix = activeCatalogProfile.id === "portolan" ? "" : `${activeCatalogProfile.id}/`;
13
- const docs = `${base}docs/${suffix}`;
14
13
  const FILES = [
15
- { href: suffix ? `${docs}llms.txt` : `${base}llms.txt`, label: "llms.txt", title: "An index of every page, for a model that fetches on demand" },
16
- { href: suffix ? `${docs}llms-full.txt` : `${base}llms-full.txt`, label: "llms-full.txt", title: "Every page in one file, for a model with a context window" },
17
- { href: docs, label: "docs/", title: "The generated markdown, page by page" },
14
+ ...(activeCatalogDocs ? [
15
+ { href: activeCatalogDocs.index, label: "llms.txt", title: "An index of every page, for a model that fetches on demand" },
16
+ { href: activeCatalogDocs.full, label: "llms-full.txt", title: "Every page in one file, for a model with a context window" },
17
+ { href: activeCatalogDocs.pages, label: "docs/", title: "The generated markdown, page by page" },
18
+ ] : []),
18
19
  { href: `${base}exports/mermaid/${suffix}`, label: "Mermaid", title: "Standalone sequence diagrams for every architecture flow" },
19
20
  ...(activeCatalogProfile.id === "example"
20
21
  ? [{ href: `${base}exports/backstage/catalog-info.yaml`, label: "Backstage", title: "Components, systems, APIs and resources for a Backstage catalog" }]
@@ -167,8 +167,15 @@ export function MethodRows({
167
167
  className={method.deprecated ? "line-through" : undefined}
168
168
  />
169
169
  {method.http ? (
170
- <span className="chip mono" title="the route this operation answers on">
171
- {method.http.method} {method.http.path}
170
+ <span
171
+ className="chip mono"
172
+ title={
173
+ method.http.method
174
+ ? "the route this operation answers on"
175
+ : "the route this operation answers on; the HTTP verb is not declared in source"
176
+ }
177
+ >
178
+ {method.http.method ? `${method.http.method} ${method.http.path}` : method.http.path}
172
179
  </span>
173
180
  ) : null}
174
181
  {method.soap ? (
@@ -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);
@@ -0,0 +1,14 @@
1
+ import { renderToStaticMarkup } from "react-dom/server";
2
+ import { expect, it } from "vitest";
3
+ import { RelationEvidencePanel } from "./RelationEvidence";
4
+
5
+ it("explains an ambiguous binding without choosing a candidate", () => {
6
+ const html = renderToStaticMarkup(<RelationEvidencePanel items={[
7
+ { kind: "binding", rule: "provider-signature", source: "di.go:12", symbol: "Build" },
8
+ { kind: "unresolved", rule: "ambiguous-binding", candidates: ["First", "Second"] },
9
+ ]} />);
10
+ for (const value of ["Why this relation exists", "inferred", "di.go:12", "Several implementations remain possible", "First", "Second"]) expect(html).toContain(value);
11
+ });
12
+ it("shows the absence of evidence explicitly", () => {
13
+ expect(renderToStaticMarkup(<RelationEvidencePanel items={[]} />)).toContain("No source evidence recorded");
14
+ });
@@ -0,0 +1,53 @@
1
+ import type { ReactNode } from "react";
2
+ import type { RelationEvidence } from "../catalog";
3
+
4
+ const labels: Record<RelationEvidence["kind"], string> = {
5
+ "call-site": "Call site", function: "Function", binding: "Dependency binding",
6
+ contract: "Contract", resolution: "Catalog resolution", unresolved: "Unresolved",
7
+ };
8
+ const rules: Record<string, string> = {
9
+ "source-expression": "Expression in source",
10
+ "source-function": "Enclosing source function",
11
+ "provider-signature": "Provider signature (inferred)",
12
+ "store-port-convention": "Storage field, type or package name (inferred)",
13
+ "domain-port-convention": "Domain port mapped to the configured store (inferred)",
14
+ "ambiguous-binding": "Several implementations remain possible",
15
+ "generated-client-method": "Method declared by generated client",
16
+ "http-expression": "Outbound HTTP expression",
17
+ "analyzed-call-path": "Static analysis path; not a runtime trace",
18
+ "client-contract": "Client contract",
19
+ "catalog-contract": "Matching interface and method",
20
+ "event-declaration": "Declared event",
21
+ "repository-sql-call": "SQL operation inside repository",
22
+ "full-path": "Recovered full HTTP path",
23
+ "exact-route": "Exact HTTP route",
24
+ "unique-suffix": "Unique route suffix (heuristic)",
25
+ "continuation-entrypoint": "Matched continuation entrypoint",
26
+ "response-to-request": "Response associated with request",
27
+ "unresolved-target": "No unique target established",
28
+ "sql-alter-table": "ALTER TABLE in migration",
29
+ "sql-view-definition": "SQL view definition",
30
+ "sql-create-table": "CREATE TABLE in migration",
31
+ "domain-root-and-repository-layout": "Domain root and repository convention (inferred)",
32
+ "migration-aggregate-annotation": "Explicit aggregate annotation in migration",
33
+ };
34
+
35
+ /** One explanation surface for all flow kinds and schema relationships. */
36
+ export function RelationEvidencePanel({ items, renderSource }: {
37
+ items: readonly RelationEvidence[];
38
+ renderSource?: (source: string) => ReactNode;
39
+ }) {
40
+ return <section aria-label="Why this relation exists" className="my-3 rounded-card border border-line bg-surface p-3">
41
+ <h3 className="label mb-2">Why this relation exists</h3>
42
+ {items.length === 0 ? <p className="text-muted">No source evidence recorded in this fragment.</p> :
43
+ <ol className="space-y-3">
44
+ {items.map((item, i) => <li key={`${item.kind}-${i}`} className="min-w-0 border-l-2 border-line pl-3">
45
+ <div className={item.kind === "unresolved" ? "text-[var(--status-unresolved)]" : "text-ink"}>{labels[item.kind]}</div>
46
+ <div className="text-muted">{rules[item.rule] ?? item.rule}</div>
47
+ {item.symbol ? <div className="mono break-all">{item.symbol}</div> : null}
48
+ {item.source ? <div className="mono break-all text-muted">{renderSource ? renderSource(item.source) : item.source}</div> : null}
49
+ {item.candidates?.length ? <div className="mt-1"><span className="text-muted">Candidates</span><ul>{item.candidates.map((candidate) => <li className="mono break-all" key={candidate}>{candidate}</li>)}</ul></div> : null}
50
+ </li>)}
51
+ </ol>}
52
+ </section>;
53
+ }
@@ -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
  <>
package/src/data.ts CHANGED
@@ -32,6 +32,11 @@ import type {
32
32
  SourceStamp,
33
33
  } from "./merge";
34
34
  import manifestJson from "../portolan.json";
35
+ import { catalogDocs } from "./catalog-docs";
36
+ // When each source last changed, as the checkout's history says - served by
37
+ // scripts/provenance.mjs at build time, because a fragment carries no
38
+ // provenance of its own (portolan.0010) and a browser cannot ask git.
39
+ import provenance from "virtual:portolan-provenance";
35
40
 
36
41
  const manifest = manifestJson as CatalogProfileManifest & { sources: string[] };
37
42
  export const catalogProfiles: CatalogProfile[] = profilesFromManifest(manifest);
@@ -56,6 +61,8 @@ export const activeCatalogProfile = catalogProfileNamed(
56
61
  requestedProfileFromUrl(),
57
62
  );
58
63
 
64
+ export const activeCatalogDocs = catalogDocs(manifestJson, activeCatalogProfile.id, import.meta.env.BASE_URL);
65
+
59
66
  /**
60
67
  * Where sources are looked for. The patterns are written out because
61
68
  * import.meta.glob resolves at build time and needs literals - and because
@@ -120,15 +127,26 @@ interface Loaded {
120
127
 
121
128
  function load(): Loaded {
122
129
  const sources: CatalogSource[] = Object.entries(modules)
123
- .map(([path, catalog]) => ({
130
+ .map(([key, catalog]) => {
124
131
  // Vite keys a glob by its pattern-relative path; the leading ../ is an
125
132
  // artefact of this file's location, not part of where anything lives.
126
- path: path.replace(/^\.\.\//, ""),
127
- // A SOURCE, not a catalog: the two stamps are optional in a file, and
128
- // the estate's authored facts carry neither.
129
- catalog: catalog as SourceCatalog,
130
- }))
131
- .filter((source) => profileIncludesSource(activeCatalogProfile, source.path));
133
+ const imported = key.replace(/^\.\.\//, "");
134
+ // A SOURCE, not a catalog: a file carries no stamp of its own, and the
135
+ // history's travels beside it (portolan.0010).
136
+ const { source, ...stamp } = provenance[imported] ?? {};
137
+ return {
138
+ imported,
139
+ // A staged site imports a source under a flattened name; the file is
140
+ // still the one in the workspace, and that is the path a project's
141
+ // root is a prefix of and a link to the file names.
142
+ path: source ?? imported,
143
+ catalog: catalog as SourceCatalog,
144
+ stamp: "commit" in stamp ? stamp : undefined,
145
+ };
146
+ })
147
+ // The profile spells its sources the way the site imports them.
148
+ .filter((source) => profileIncludesSource(activeCatalogProfile, source.imported))
149
+ .map(({ imported: _imported, ...source }) => source);
132
150
 
133
151
  const merged = mergeCatalogs(sources);
134
152
  // Enriched before it is validated: the edges the flows imply are part of