@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
@@ -297,7 +297,11 @@ export interface RpcMethod {
297
297
  }
298
298
 
299
299
  export interface HttpRoute {
300
- /** Upper case: `POST`. */
300
+ /**
301
+ * Upper case: `POST`. Empty when a framework extractor proved the mount but
302
+ * no declaration proved the verb; such a route is never matched against an
303
+ * outbound call, and renderers show the path alone.
304
+ */
301
305
  method: string;
302
306
  /** As templated in the document: `/v1/users/{id}`. */
303
307
  path: string;
@@ -361,7 +365,32 @@ export interface EdgeVia {
361
365
  flow: string; // Flow.slug
362
366
  step: string; // Step.id
363
367
  }
368
+ /** Source facts remain separate from the merge's choice of provider. */
369
+ export interface HTTPDestination {
370
+ callSite: string;
371
+ endpointExpression: string;
372
+ method: string;
373
+ localPath?: string;
374
+ baseURL?: HTTPBaseURL;
375
+ serviceDiscoveryAlias?: string;
376
+ fullPath?: string;
377
+ join?: { expression: string; source: string };
378
+ /** Runtime URL modifiers after the proven join; not evaluated statically. */
379
+ transforms?: { expression: string; source: string }[];
380
+ resolution?: { basis: "full-path" | "exact-route" | "unique-suffix"; provider: string; route: string };
381
+ }
382
+ export interface HTTPBaseURL {
383
+ expression: string;
384
+ configField?: string;
385
+ environmentVariable?: string;
386
+ value?: string;
387
+ kind: "literal" | "config-default" | "symbolic";
388
+ source: string;
389
+ optionSource?: string;
390
+ }
364
391
  export interface RpcCall {
392
+ evidence?: RelationEvidence[];
393
+ destination?: HTTPDestination;
365
394
  id: string; // "<proto.package.Service>/<Method>"
366
395
  peer: string; // service id if resolved, else raw name
367
396
  status: Status;
@@ -447,7 +476,9 @@ export interface Aggregate {
447
476
  slug: string;
448
477
  name: string;
449
478
  readme: string;
450
- /** Name of the entity that is the aggregate root; must be one of `entities`. */
479
+ /** A source grouping has no confirmed aggregate boundary. Omitted for aggregates. */
480
+ kind?: "model-group";
481
+ /** Name of the root entity; empty only for a model-group. */
451
482
  root: string;
452
483
  entities: Entity[];
453
484
  valueObjects: ValueObject[];
@@ -627,6 +658,10 @@ export interface ChannelMessage {
627
658
  title?: string;
628
659
  doc?: string;
629
660
  direction: ChannelDirection;
661
+ /** Normalized payload serialization, such as `msgpack`. */
662
+ encoding?: string;
663
+ /** Exact media type declared by the source contract. */
664
+ contentType?: string;
630
665
  }
631
666
  export interface EventConsumer {
632
667
  service: string;
@@ -730,7 +765,7 @@ export interface RedisKeyspace {
730
765
  value?: string;
731
766
  source?: string;
732
767
  /** Aggregate or block whose value this key family holds, when provable. */
733
- persists?: { aggregate?: string; block?: string };
768
+ persists?: { aggregate?: string; block?: string; evidence?: RelationEvidence[] };
734
769
  /** Individual client calls, before they are folded into `operations`. */
735
770
  accesses?: RedisAccess[];
736
771
  }
@@ -763,14 +798,32 @@ export const TABLE_ROLES: readonly TableRole[] = [
763
798
  ] as const;
764
799
 
765
800
  export interface Table {
801
+ evidence?: RelationEvidence[];
766
802
  id: string; // "<store id>.<table>"
767
803
  name: string;
768
804
  doc?: string;
769
805
  columns: Column[];
770
806
  indexes?: TableIndex[];
771
807
  /** The domain object this table holds: an aggregate id, and optionally a block id. */
772
- persists?: { aggregate?: string; block?: string };
808
+ persists?: { aggregate?: string; block?: string; evidence?: RelationEvidence[] };
773
809
  role?: TableRole;
810
+ /** Source-backed repository methods that read or write this table. */
811
+ accesses?: TableAccess[];
812
+ }
813
+
814
+ export type TableOperation = "read" | "write" | "delete";
815
+
816
+ export const TABLE_OPERATIONS: readonly TableOperation[] = [
817
+ "read",
818
+ "write",
819
+ "delete",
820
+ ] as const;
821
+
822
+ export interface TableAccess {
823
+ operation: TableOperation;
824
+ /** Enclosing adapter method, for example `Postgres.Save`. */
825
+ method?: string;
826
+ source?: string;
774
827
  }
775
828
 
776
829
  export interface TableIndex {
@@ -832,7 +885,7 @@ export interface View {
832
885
  /** The SELECT, as the migration declares it. Shown, never parsed. */
833
886
  definition?: string;
834
887
  /** The domain object this view presents, when it presents exactly one. */
835
- persists?: { aggregate?: string; block?: string };
888
+ persists?: { aggregate?: string; block?: string; evidence?: RelationEvidence[] };
836
889
  /** Migration or model file, as a reader would open it. */
837
890
  source?: string;
838
891
  }
@@ -887,13 +940,24 @@ export interface Participant {
887
940
  label?: string;
888
941
  }
889
942
  export type FlowNode = Step | Parallel | Alt | Loop;
943
+ /** Source facts used to derive a relationship; not a runtime trace. */
944
+ export interface RelationEvidence {
945
+ kind: "call-site" | "function" | "binding" | "contract" | "resolution" | "unresolved";
946
+ rule: string;
947
+ source?: string;
948
+ symbol?: string;
949
+ candidates?: string[];
950
+ }
951
+
890
952
  export interface Step {
953
+ evidence?: RelationEvidence[];
954
+ destination?: HTTPDestination;
891
955
  type: "step";
892
956
  id: string;
893
957
  from: string;
894
958
  to: string; // participant ids; from === to is a self-message
895
959
  kind: "rpc" | "event" | "call" | "response";
896
- ref?: string; // Event.id or RpcCall.id - resolvable, or status must be unresolved
960
+ ref?: string; // Event.id, RpcCall.id or provided RPC method id; otherwise unresolved
897
961
  label?: string;
898
962
  status: Status;
899
963
  note?: string;
@@ -45,6 +45,23 @@ describe("store validation", () => {
45
45
  expect(() => validateCatalog(clone())).not.toThrow();
46
46
  });
47
47
 
48
+ it("accepts source-backed table readers and writers", () => {
49
+ const good = clone();
50
+ omsStore(good).tables[0]!.accesses = [
51
+ { operation: "read", method: "Postgres.ByID", source: "postgres.go:40" },
52
+ { operation: "write", method: "Postgres.Save", source: "postgres.go:20" },
53
+ ];
54
+ expect(() => validateCatalog(good)).not.toThrow();
55
+ });
56
+
57
+ it("rejects an unknown table access operation", () => {
58
+ const bad = clone();
59
+ omsStore(bad).tables[0]!.accesses = [
60
+ { operation: "merge" as "read", method: "Postgres.Save" },
61
+ ];
62
+ expect(failureOf(bad).message).toContain("access operation");
63
+ });
64
+
48
65
  it("rejects a foreign key into a table nobody declared", () => {
49
66
  const bad = clone();
50
67
  const table = omsStore(bad).tables.find((t) => t.name === "order_items");
@@ -16,6 +16,7 @@ import {
16
16
  REDIS_OPERATIONS,
17
17
  STORE_KINDS,
18
18
  STREAMING,
19
+ TABLE_OPERATIONS,
19
20
  TABLE_ROLES,
20
21
  aggregateBlocks,
21
22
  allAggregates,
@@ -124,6 +125,18 @@ function validateChannels(service: Service): void {
124
125
  `service ${service.id} / channel ${channel.address}`,
125
126
  );
126
127
  }
128
+ if (message.encoding !== undefined && (typeof message.encoding !== "string" || message.encoding === "")) {
129
+ fail(
130
+ `message "${message.name}" on channel "${channel.address}" has an empty encoding`,
131
+ `service ${service.id} / channel ${channel.address}`,
132
+ );
133
+ }
134
+ if (message.contentType !== undefined && (typeof message.contentType !== "string" || message.contentType === "")) {
135
+ fail(
136
+ `message "${message.name}" on channel "${channel.address}" has an empty content type`,
137
+ `service ${service.id} / channel ${channel.address}`,
138
+ );
139
+ }
127
140
  const key = `${message.direction} ${message.name}`;
128
141
  if (seen.has(key)) {
129
142
  fail(
@@ -142,6 +155,15 @@ export function validateCatalog(catalog: Catalog): Catalog {
142
155
 
143
156
  const eventIds = new Set<string>();
144
157
  const rpcIds = new Set<string>();
158
+ const providedRpcRefs = new Set(
159
+ allExternals(catalog).flatMap((external) =>
160
+ external.provides.flatMap((provided) =>
161
+ provided.methods.map(
162
+ (method) => `${external.id}|${provided.id}/${method.name}`,
163
+ ),
164
+ ),
165
+ ),
166
+ );
145
167
  const storeIds = new Set(allStores(catalog).map((store) => store.id));
146
168
 
147
169
  assertUniqueSlugs(
@@ -232,6 +254,13 @@ export function validateCatalog(catalog: Catalog): Catalog {
232
254
  technologies.add(technology);
233
255
  }
234
256
  for (const call of service.consumes) rpcIds.add(call.id);
257
+ for (const provided of service.provides) {
258
+ for (const method of provided.methods) {
259
+ providedRpcRefs.add(
260
+ `${service.id}|${provided.id}/${method.name}`,
261
+ );
262
+ }
263
+ }
235
264
  for (const provided of [
236
265
  ...service.provides,
237
266
  ...(service.copies ?? []),
@@ -589,10 +618,14 @@ export function validateCatalog(catalog: Catalog): Catalog {
589
618
  );
590
619
  }
591
620
  if (step.ref !== undefined && step.status !== "unresolved") {
592
- const resolves = eventIds.has(step.ref) || rpcIds.has(step.ref);
621
+ const resolves =
622
+ eventIds.has(step.ref) ||
623
+ rpcIds.has(step.ref) ||
624
+ (step.kind === "rpc" &&
625
+ providedRpcRefs.has(`${step.to}|${step.ref}`));
593
626
  if (!resolves) {
594
627
  fail(
595
- `flow "${flow.slug}" step "${step.id}": ref "${step.ref}" resolves to neither an Event nor an RpcCall, and status is "${step.status}" rather than "unresolved"`,
628
+ `flow "${flow.slug}" step "${step.id}": ref "${step.ref}" resolves to neither an Event, an RpcCall nor a method provided by "${step.to}", and status is "${step.status}" rather than "unresolved"`,
596
629
  `flow ${flow.id} / step ${step.id}`,
597
630
  );
598
631
  }
@@ -990,6 +1023,14 @@ function validateStores(catalog: Catalog): void {
990
1023
  where,
991
1024
  );
992
1025
  }
1026
+ for (const access of table.accesses ?? []) {
1027
+ if (!TABLE_OPERATIONS.includes(access.operation)) {
1028
+ fail(
1029
+ `table "${table.id}" has access operation "${access.operation}"; expected one of ${TABLE_OPERATIONS.join(", ")}`,
1030
+ where,
1031
+ );
1032
+ }
1033
+ }
993
1034
 
994
1035
  const own = columnsOfTable.get(table.id) ?? new Set<string>();
995
1036
  if (own.size !== table.columns.length) {
@@ -1311,6 +1352,15 @@ function validateBlocks(catalog: Catalog, aggregate: Aggregate): void {
1311
1352
 
1312
1353
  validateEnums(aggregate);
1313
1354
 
1355
+ if (aggregate.kind !== undefined && aggregate.kind !== "model-group") {
1356
+ fail(`aggregate "${aggregate.id}" has an unknown kind`, `aggregate ${aggregate.id}`);
1357
+ }
1358
+ if (aggregate.kind === "model-group") {
1359
+ if (aggregate.root !== "" || aggregate.lifecycle) {
1360
+ fail(`model group "${aggregate.id}" cannot declare an aggregate root or lifecycle`, `aggregate ${aggregate.id}`);
1361
+ }
1362
+ return;
1363
+ }
1314
1364
  if (!aggregate.root) {
1315
1365
  fail(
1316
1366
  `aggregate "${aggregate.id}" names no root entity`,
@@ -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
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { KindIcon } from "../components/kind";
8
8
  import type { Kind } from "../lib/kinds";
9
- import { activeCatalogProfile, catalog } from "../data";
9
+ import { activeCatalogDocs, catalog } from "../data";
10
10
  import type { ChatPageContext } from "./page-context";
11
11
  import { contextQuestions } from "./page-context";
12
12
 
@@ -36,8 +36,6 @@ function examples(): Example[] {
36
36
  return out;
37
37
  }
38
38
 
39
- const base = import.meta.env.BASE_URL;
40
-
41
39
  export function Starter({
42
40
  onAsk,
43
41
  page,
@@ -51,10 +49,6 @@ export function Starter({
51
49
  question,
52
50
  }))
53
51
  : examples();
54
- const profileSuffix =
55
- activeCatalogProfile.id === "portolan" ? "" : `${activeCatalogProfile.id}/`;
56
- const docsRoot = `${base}docs/${profileSuffix}`;
57
- const indexHref = profileSuffix ? `${docsRoot}llms.txt` : `${base}llms.txt`;
58
52
 
59
53
  return (
60
54
  <div className="pt-1">
@@ -79,10 +73,10 @@ export function Starter({
79
73
  <div className="mono truncate text-muted" title={page.id}>{page.id}</div>
80
74
  </div>
81
75
  </div>
82
- <div className="mono mt-2.5 flex flex-wrap items-center gap-x-2 gap-y-1 border-t border-line pt-2 text-muted">
76
+ {activeCatalogDocs ? <div className="mono mt-2.5 flex flex-wrap items-center gap-x-2 gap-y-1 border-t border-line pt-2 text-muted">
83
77
  <span className="text-faint">model context</span>
84
78
  <a
85
- href={indexHref}
79
+ href={activeCatalogDocs.index}
86
80
  target="_blank"
87
81
  rel="noreferrer"
88
82
  className="rounded-control text-accent hover:underline"
@@ -91,7 +85,7 @@ export function Starter({
91
85
  </a>
92
86
  {page.docPath ? (
93
87
  <a
94
- href={`${docsRoot}${page.docPath}`}
88
+ href={`${activeCatalogDocs.pages}${page.docPath}`}
95
89
  target="_blank"
96
90
  rel="noreferrer"
97
91
  className="max-w-full truncate rounded-control text-accent hover:underline"
@@ -100,7 +94,7 @@ export function Starter({
100
94
  current catalog page
101
95
  </a>
102
96
  ) : null}
103
- </div>
97
+ </div> : null}
104
98
  </div>
105
99
  ) : null}
106
100
  <div className="label mt-5 mb-1">try one</div>
@@ -0,0 +1,27 @@
1
+ import { afterEach, expect, it, vi } from "vitest";
2
+ import { catalogDocs } from "../catalog-docs";
3
+
4
+ afterEach(() => {
5
+ vi.unstubAllGlobals();
6
+ vi.doUnmock("../data");
7
+ vi.resetModules();
8
+ });
9
+
10
+ it.each([
11
+ { id: "default", generate: [{ plugin: "markdown", out: "docs" }], index: "/portolan/llms.txt", pages: "/portolan/docs/" },
12
+ { id: "example", generate: [
13
+ { plugin: "markdown", catalog: "portolan", out: "docs" },
14
+ { plugin: "markdown", catalog: "example", out: "docs/example" },
15
+ ], index: "/portolan/docs/example/llms.txt", pages: "/portolan/docs/example/" },
16
+ ])("loads the index and catalog pages for $id", async ({ id, generate, index, pages }) => {
17
+ vi.doMock("../data", () => ({ activeCatalogDocs: catalogDocs({ generate }, id, "/portolan/") }));
18
+ const fetcher = vi.fn().mockResolvedValue(new Response("# Catalog documentation"));
19
+ vi.stubGlobal("fetch", fetcher);
20
+ const tools = await import("./tools");
21
+
22
+ expect(await tools.loadIndex()).toBe("# Catalog documentation");
23
+ expect(fetcher).toHaveBeenLastCalledWith(index);
24
+ fetcher.mockResolvedValue(new Response("# Auth service"));
25
+ expect(await tools.readPage("docs/auth/README.md")).toBe("# Auth service");
26
+ expect(fetcher).toHaveBeenLastCalledWith(`${pages}auth/README.md`);
27
+ });
package/src/chat/tools.ts CHANGED
@@ -6,19 +6,14 @@
6
6
 
7
7
  import { jsonSchema, tool } from "ai";
8
8
  import { clipPage, pagePath, TOOL_SPECS } from "./prompt";
9
- import { activeCatalogProfile } from "../data";
10
-
11
- const base = import.meta.env.BASE_URL;
12
- const profileSuffix =
13
- activeCatalogProfile.id === "portolan" ? "" : `${activeCatalogProfile.id}/`;
14
- const docsBase = `${base}docs/${profileSuffix}`;
15
- const indexUrl = profileSuffix ? `${docsBase}llms.txt` : `${base}llms.txt`;
9
+ import { activeCatalogDocs } from "../data";
16
10
 
17
11
  /** One page of the generated docs, as the model gets it. */
18
12
  export async function readPage(raw: unknown): Promise<string> {
19
13
  const path = pagePath(raw);
20
14
  if (!path) return "That is not a page of the catalog. Use a path from the index.";
21
- const response = await fetch(`${docsBase}${path.slice("docs/".length)}`);
15
+ if (!activeCatalogDocs) return "No documentation is configured for this catalog.";
16
+ const response = await fetch(`${activeCatalogDocs.pages}${path.slice("docs/".length)}`);
22
17
  if (!response.ok) return `No page at ${path}. Use a path from the index.`;
23
18
  return clipPage(await response.text());
24
19
  }
@@ -26,7 +21,8 @@ export async function readPage(raw: unknown): Promise<string> {
26
21
  /** llms.txt, once per session. */
27
22
  let indexPromise: Promise<string> | null = null;
28
23
  export function loadIndex(): Promise<string> {
29
- indexPromise ??= fetch(indexUrl).then((response) => {
24
+ if (!activeCatalogDocs) return Promise.reject(new Error("No documentation is configured for this catalog."));
25
+ indexPromise ??= fetch(activeCatalogDocs.index).then((response) => {
30
26
  if (!response.ok) {
31
27
  indexPromise = null;
32
28
  throw new Error("the catalog index (llms.txt) could not be read");
@@ -6,23 +6,25 @@ import { absoluteTime, plural, relativeTime } from "../lib/format";
6
6
  /**
7
7
  * Where the catalog on this page came from, and when.
8
8
  *
9
- * The header says one date and one commit, and both are summaries of a corpus:
10
- * the date is the OLDEST of the sources, because a merged catalog is exactly
11
- * as fresh as its stalest part, and the commit is a count whenever the sources
12
- * do not agree on one. A summary of many numbers is the right thing to put in
13
- * a header and the wrong thing to leave a reader with - "6 sources · 15 hours
9
+ * A source is dated by the commit that last changed it, read off the
10
+ * checkout's history rather than out of the file (portolan.0010). The header
11
+ * says one date and one commit, and both are summaries of a corpus: the date
12
+ * is the OLDEST of the sources, because a merged catalog is exactly as fresh
13
+ * as its stalest part, and the commit is a count whenever the sources do not
14
+ * agree on one. A summary of many numbers is the right thing to put in a
15
+ * header and the wrong thing to leave a reader with - "6 sources · 15 hours
14
16
  * ago" answers "is this current" and refuses "which part is not", which is the
15
17
  * question anyone who did not like the first answer asks next.
16
18
  *
17
19
  * So the stamp opens. Behind it is one row per commit, newest first, and the
18
20
  * row the header is quoting says so. Rows are per COMMIT and not per file
19
- * because twenty-eight fragments written by one commit are one source of
21
+ * because twenty-eight fragments changed by one commit are one source of
20
22
  * facts, and it is the same count the header prints.
21
23
  */
22
24
  export function CatalogStamp() {
23
25
  const groups = byCommit(catalogSources);
24
26
  const authored = catalogSources.filter((source) => !source.commit);
25
- const summary = `catalog generated ${absoluteTime(catalog.generatedAt)} from commit ${catalog.commit}`;
27
+ const summary = `catalog last changed ${absoluteTime(catalog.generatedAt)}, ${catalog.commit}`;
26
28
 
27
29
  return (
28
30
  <Popover className="ml-auto">
@@ -43,7 +45,7 @@ export function CatalogStamp() {
43
45
  >
44
46
  <div className="label mb-1.5 px-1">
45
47
  {groups.length} {plural(groups.length, "source")}, by the commit each
46
- was generated from
48
+ last changed in
47
49
  </div>
48
50
  <dl className="mono grid grid-cols-[auto_1fr_auto] items-baseline gap-x-3 gap-y-1 px-1">
49
51
  {groups.map((group, i) => (
@@ -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 ? (