@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,6 +1,11 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import { catalog, index } from "../testing/estate";
3
- import { backlinkCount, backlinksFor, stepsInto } from "./backlinks";
3
+ import {
4
+ backlinkCount,
5
+ backlinksFor,
6
+ distinctBacklinks,
7
+ stepsInto,
8
+ } from "./backlinks";
4
9
  import type { BacklinkGroup, BacklinkTarget } from "./backlinks";
5
10
  import type { Kind } from "./kinds";
6
11
 
@@ -290,6 +295,16 @@ describe("grouping", () => {
290
295
  const g = groups({ kind: "service", id: "shop.oms" });
291
296
  expect(backlinkCount(g)).toBe(g.reduce((n, x) => n + x.links.length, 0));
292
297
  });
298
+
299
+ it("collapses repeated entities for compact backlink lines", () => {
300
+ const links = distinctBacklinks(
301
+ groups({ kind: "store", id: "shop.oms.pg" }),
302
+ );
303
+ const ids = links.map((link) => `${link.kind}:${link.id}`);
304
+
305
+ expect(ids).toEqual([...new Set(ids)]);
306
+ expect(ids).toContain("aggregate:shop.oms.order");
307
+ });
293
308
  });
294
309
 
295
310
  describe("the word that names it", () => {
@@ -100,6 +100,26 @@ export function backlinkCount(groups: readonly BacklinkGroup[]): number {
100
100
  return groups.reduce((n, g) => n + g.links.length, 0);
101
101
  }
102
102
 
103
+ /**
104
+ * One link per entity for compact surfaces that cannot show each link's
105
+ * reason. The full section deliberately keeps separate edges (for example an
106
+ * aggregate persisted by two tables), but two identical chips would only look
107
+ * like an accidental duplicate when `via` is not visible.
108
+ */
109
+ export function distinctBacklinks(
110
+ groups: readonly BacklinkGroup[],
111
+ ): Backlink[] {
112
+ const seen = new Set<string>();
113
+ return groups.flatMap((group) =>
114
+ group.links.filter((link) => {
115
+ const key = `${link.kind}:${link.id}`;
116
+ if (seen.has(key)) return false;
117
+ seen.add(key);
118
+ return true;
119
+ }),
120
+ );
121
+ }
122
+
103
123
  // ---------------------------------------------------------------------------
104
124
  // Row builders. One per kind of thing that can do the pointing.
105
125
  // ---------------------------------------------------------------------------
@@ -57,6 +57,24 @@ describe("diffCatalogs", () => {
57
57
  });
58
58
 
59
59
  describe("diffCatalogs: what a reviewer is looking for", () => {
60
+ it("calls a change between known message encodings breaking", () => {
61
+ const before = JSON.parse(JSON.stringify(catalog)) as Catalog;
62
+ const service = before.contexts[0]!.services[0]!;
63
+ service.channels = [{
64
+ address: "shop.cart.basket",
65
+ messages: [{ name: "cart.BasketCreated", direction: "send", encoding: "json" }],
66
+ }];
67
+ const after = JSON.parse(JSON.stringify(before)) as Catalog;
68
+ after.contexts[0]!.services[0]!.channels![0]!.messages[0]!.encoding = "msgpack";
69
+
70
+ expect(diffCatalogs(before, after)).toEqual([{
71
+ kind: "message.encoding",
72
+ severity: "breaking",
73
+ where: service.id,
74
+ summary: "send message cart.BasketCreated on shop.cart.basket uses msgpack, was json",
75
+ }]);
76
+ });
77
+
60
78
  // The finding the whole report exists for.
61
79
  it("names a new event nothing consumes", () => {
62
80
  const changes = edited((c) => {
@@ -245,7 +245,7 @@ function diffOwners(before: Service, after: Service, add: Add): void {
245
245
  function signature(method: RpcMethod): string {
246
246
  const parts = [method.request ?? "?", method.response ?? "?"];
247
247
  if (method.streaming) parts.push(method.streaming);
248
- if (method.http) parts.push(`${method.http.method} ${method.http.path}`);
248
+ if (method.http) parts.push(`${method.http.method || "?"} ${method.http.path}`);
249
249
  if (method.soap) {
250
250
  parts.push(
251
251
  [
@@ -331,7 +331,7 @@ function diffCalls(before: Service, after: Service, add: Add): void {
331
331
  function diffChannels(before: Service, after: Service, add: Add): void {
332
332
  const was = byId(before.channels ?? [], (c) => c.address);
333
333
  const now = byId(after.channels ?? [], (c) => c.address);
334
- const { added, removed } = partition(was, now);
334
+ const { added, removed, kept } = partition(was, now);
335
335
 
336
336
  for (const address of added) {
337
337
  add("channel.added", "addition", before.id, `"${before.id}" declares channel ${address}`);
@@ -339,6 +339,24 @@ function diffChannels(before: Service, after: Service, add: Add): void {
339
339
  for (const address of removed) {
340
340
  add("channel.removed", "breaking", before.id, `"${before.id}" no longer declares channel ${address}`);
341
341
  }
342
+ for (const address of kept) {
343
+ const previous = byId(was.get(address)!.messages, (message) => `${message.direction} ${message.name}`);
344
+ const current = byId(now.get(address)!.messages, (message) => `${message.direction} ${message.name}`);
345
+ const messages = partition(previous, current).kept;
346
+ for (const id of messages) {
347
+ const a = previous.get(id)!;
348
+ const b = current.get(id)!;
349
+ const from = a.encoding || a.contentType || "";
350
+ const to = b.encoding || b.contentType || "";
351
+ if (from === to) continue;
352
+ add(
353
+ "message.encoding",
354
+ from && to ? "breaking" : "change",
355
+ before.id,
356
+ `${a.direction} message ${a.name} on ${address} uses ${to || "an unspecified encoding"}, was ${from || "unspecified"}`,
357
+ );
358
+ }
359
+ }
342
360
  }
343
361
 
344
362
  function diffServiceStores(before: Service, after: Service, add: Add): void {
package/src/lib/derive.ts CHANGED
@@ -424,6 +424,7 @@ export type ProblemKind =
424
424
  | "shared-channel"
425
425
  | "channel-undeclared"
426
426
  | "channel-unpublished"
427
+ | "message-encoding"
427
428
  | "subscription-unresolved";
428
429
 
429
430
  /**
@@ -0,0 +1,9 @@
1
+ export interface DjangoAggregateCandidates {
2
+ app: string;
3
+ models: Array<{ name: string; path: string; line: number }>;
4
+ }
5
+
6
+ /** Evidence emitted by extract-django, carried through the warning protocol. */
7
+ export function djangoAggregateCandidates(message: string): DjangoAggregateCandidates | null;
8
+
9
+ export function djangoAggregateMessage(message: string): string;
@@ -0,0 +1,36 @@
1
+ // Plain JavaScript on purpose: the CLI's `init` runs this from node_modules,
2
+ // where Node strips no types, so the one parser the host and the browser
3
+ // share cannot be a .ts file. Types live in django-aggregates.d.mts.
4
+
5
+ const MARKER = "; aggregate candidates: ";
6
+
7
+ /**
8
+ * Evidence emitted by extract-django, carried through the warning protocol.
9
+ *
10
+ * @param {string} message
11
+ * @returns {import("./django-aggregates.d.mts").DjangoAggregateCandidates | null}
12
+ */
13
+ export function djangoAggregateCandidates(message) {
14
+ const start = message.lastIndexOf(MARKER);
15
+ if (start < 0) return null;
16
+ try {
17
+ const value = JSON.parse(message.slice(start + MARKER.length));
18
+ if (!value || typeof value.app !== "string" || !/^[\p{L}_][\p{L}\p{N}_]*(?:\.[\p{L}_][\p{L}\p{N}_]*)*$/u.test(value.app)
19
+ || !Array.isArray(value.models) || !value.models.length) return null;
20
+ const models = [];
21
+ for (const model of value.models) {
22
+ if (!model || typeof model.name !== "string" || !/^[\p{L}_][\p{L}\p{N}_]*$/u.test(model.name)
23
+ || typeof model.path !== "string" || !model.path || !Number.isSafeInteger(model.line) || model.line < 1) return null;
24
+ models.push({ name: model.name, path: model.path, line: model.line });
25
+ }
26
+ // The extractor's option accepts a class name, so duplicate names cannot
27
+ // be resolved safely by this picker.
28
+ if (new Set(models.map((model) => model.name)).size !== models.length) return null;
29
+ return { app: value.app, models };
30
+ } catch { return null; }
31
+ }
32
+
33
+ /** @param {string} message */
34
+ export function djangoAggregateMessage(message) {
35
+ return djangoAggregateCandidates(message) ? message.slice(0, message.lastIndexOf(MARKER)) : message;
36
+ }
@@ -0,0 +1,29 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { djangoAggregateCandidates } from "./django-aggregates";
3
+ import { warningDiagnostic } from "./warnings";
4
+ import { publicSetupFrom } from "./setup-info";
5
+
6
+ describe("Django aggregate evidence", () => {
7
+ const candidates = { app: "billing.records", models: Array.from({ length: 29 }, (_, index) => ({ name: `Record${index}`, path: "source/billing/records/models.py", line: index + 1 })) };
8
+ const message = `source/billing/records: no model called Records, and 29 models to choose from: name the root in the aggregates option; aggregate candidates: ${JSON.stringify(candidates)}`;
9
+
10
+ it("retains all candidates beyond the public warning text limit", () => {
11
+ const warning = warningDiagnostic({ plugin: "django-domain", message });
12
+ expect(warning.aggregateCandidates).toEqual(candidates);
13
+ expect(warning.message).not.toContain("aggregate candidates:");
14
+ const setup = publicSetupFrom({ sources: [], extract: [{ plugin: "django-domain", in: "source", out: "data" }] }, {
15
+ version: 1, manifestSha256: "current", mode: "write", status: "ok", startedAt: "2026-09-10T00:00:00Z", finishedAt: "2026-09-10T00:00:01Z", durationMs: 1000,
16
+ steps: [{ ordinal: 0, phase: "extract", plugin: "django-domain", input: "source", output: "data", status: "written", durationMs: 10, fileCount: 1, changedCount: 1, files: ["data/domain.json"], warnings: [message], diagnostics: [{ ...warning, message, count: 1, project: "", phase: "extract" }] }],
17
+ }, "current");
18
+ expect(setup.run?.steps[0]?.diagnostics[0]?.aggregateCandidates).toEqual(candidates);
19
+ expect(setup.run?.steps[0]?.diagnostics[0]?.message.length).toBeLessThan(500);
20
+ });
21
+
22
+ it("leaves old warnings usable and rejects malformed or duplicate choices", () => {
23
+ expect(djangoAggregateCandidates("legacy warning")).toBeNull();
24
+ expect(djangoAggregateCandidates("; aggregate candidates: {broken")).toBeNull();
25
+ for (const value of [{ app: "../records", models: candidates.models }, { ...candidates, models: [candidates.models[0], candidates.models[0]] }, { ...candidates, models: [{ name: "Record", path: "models.py", line: 0 }] }]) {
26
+ expect(djangoAggregateCandidates(`; aggregate candidates: ${JSON.stringify(value)}`)).toBeNull();
27
+ }
28
+ });
29
+ });
@@ -0,0 +1,5 @@
1
+ // The parser itself is django-aggregates.mjs, which the CLI's `init` reads
2
+ // from node_modules, where Node strips no types. This name is what the site
3
+ // imports, so the .mjs stays the one place it is written.
4
+ export { djangoAggregateCandidates, djangoAggregateMessage } from "./django-aggregates.mjs";
5
+ export type { DjangoAggregateCandidates } from "./django-aggregates.mjs";
@@ -0,0 +1,87 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { Catalog, Channel } from "../catalog";
3
+ import {
4
+ isKafkaChannel,
5
+ kafkaHandoffChannels,
6
+ kafkaUiTopicUrl,
7
+ normalizeKafkaUiUrl,
8
+ } from "./kafka-ui";
9
+
10
+ describe("Kafka UI integration", () => {
11
+ it("accepts web URLs and rejects values that cannot be opened safely", () => {
12
+ expect(normalizeKafkaUiUrl(" https://kafka.example/ ")).toBe(
13
+ "https://kafka.example",
14
+ );
15
+ expect(normalizeKafkaUiUrl("javascript:alert(1)")).toBeNull();
16
+ expect(normalizeKafkaUiUrl("not a URL")).toBeNull();
17
+ expect(normalizeKafkaUiUrl(" ")).toBe("");
18
+ });
19
+
20
+ it("opens the exact topic from a Kafbat cluster URL behind a path prefix", () => {
21
+ expect(
22
+ kafkaUiTopicUrl(
23
+ "https://ops.example/kafka/ui/clusters/prod/all-topics/old",
24
+ "orders/new",
25
+ ),
26
+ ).toBe(
27
+ "https://ops.example/kafka/ui/clusters/prod/all-topics/orders%2Fnew",
28
+ );
29
+ });
30
+
31
+ it("keeps a plain installation URL as the useful fallback", () => {
32
+ expect(kafkaUiTopicUrl("https://kafka.example", "orders.created")).toBe(
33
+ "https://kafka.example",
34
+ );
35
+ });
36
+
37
+ it("recognizes both Kafka-labelled cards and explicit Kafka handoffs", () => {
38
+ const labelled: Channel = {
39
+ address: "orders.created",
40
+ title: "Kafka · orders.created",
41
+ messages: [],
42
+ };
43
+ const flowOnly: Channel = {
44
+ address: "payments.accepted",
45
+ title: "message stream",
46
+ messages: [],
47
+ };
48
+ const nats: Channel = {
49
+ address: "shop.cart",
50
+ title: "JetStream subject",
51
+ messages: [],
52
+ };
53
+ const catalog = {
54
+ flows: [
55
+ {
56
+ id: "payment-kafka",
57
+ slug: "payment-kafka",
58
+ name: "Payment Kafka",
59
+ summary: "",
60
+ owner: "payments",
61
+ participants: [],
62
+ steps: [
63
+ {
64
+ type: "step",
65
+ id: "publish",
66
+ from: "payments",
67
+ to: "broker",
68
+ kind: "event",
69
+ status: "verified",
70
+ handoff: {
71
+ kind: "message",
72
+ transport: "kafka",
73
+ channel: "payments.accepted",
74
+ direction: "send",
75
+ },
76
+ },
77
+ ],
78
+ },
79
+ ],
80
+ } as Pick<Catalog, "flows">;
81
+ const handoffs = kafkaHandoffChannels(catalog);
82
+
83
+ expect(isKafkaChannel(labelled, handoffs)).toBe(true);
84
+ expect(isKafkaChannel(flowOnly, handoffs)).toBe(true);
85
+ expect(isKafkaChannel(nats, handoffs)).toBe(false);
86
+ });
87
+ });
@@ -0,0 +1,105 @@
1
+ // The optional hand-off from a catalogued Kafka topic to the operational UI.
2
+ //
3
+ // The configured value belongs to the reader, not to the catalog: two readers
4
+ // can use different Kafka UI installations for the same generated estate. It
5
+ // therefore lives in localStorage, like the editor and display preferences.
6
+ // A cluster URL gives us enough information to open the exact topic; a plain
7
+ // installation URL remains useful and opens Kafka UI without inventing a
8
+ // cluster name.
9
+
10
+ import { create } from "zustand";
11
+ import type { Catalog, Channel } from "../catalog";
12
+ import { walkSteps } from "../catalog";
13
+
14
+ export const KAFKA_UI_KEY = "portolan.integrations.kafka-ui";
15
+
16
+ export function normalizeKafkaUiUrl(value: string): string | null {
17
+ const clean = value.trim();
18
+ if (!clean) return "";
19
+ try {
20
+ const url = new URL(clean);
21
+ if (url.protocol !== "http:" && url.protocol !== "https:") return null;
22
+ url.hash = "";
23
+ return url.toString().replace(/\/$/, "");
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ /** Build Kafbat's topic route when the configured URL names a cluster. */
30
+ export function kafkaUiTopicUrl(
31
+ configured: string,
32
+ topic: string,
33
+ ): string | null {
34
+ const normalized = normalizeKafkaUiUrl(configured);
35
+ if (!normalized) return null;
36
+ const url = new URL(normalized);
37
+ const match = url.pathname.match(
38
+ /^(.*\/ui\/clusters\/[^/]+)(?:\/.*)?$/,
39
+ );
40
+ if (!match?.[1]) return normalized;
41
+ url.pathname = `${match[1]}/all-topics/${encodeURIComponent(topic)}`;
42
+ url.search = "";
43
+ url.hash = "";
44
+ return url.toString();
45
+ }
46
+
47
+ /** Topic addresses whose source-backed flow explicitly says Kafka. */
48
+ export function kafkaHandoffChannels(catalog: Pick<Catalog, "flows">): Set<string> {
49
+ const channels = new Set<string>();
50
+ for (const flow of catalog.flows) {
51
+ for (const step of walkSteps(flow.steps)) {
52
+ if (step.handoff?.transport.toLowerCase() === "kafka") {
53
+ channels.add(step.handoff.channel);
54
+ }
55
+ }
56
+ }
57
+ return channels;
58
+ }
59
+
60
+ /**
61
+ * A channel is Kafka only when an extractor said so in its presentation facts
62
+ * or a matching flow carries explicit Kafka transport evidence. Channel itself
63
+ * predates the transport field, so address alone is deliberately insufficient.
64
+ */
65
+ export function isKafkaChannel(
66
+ channel: Pick<Channel, "address" | "title" | "doc">,
67
+ handoffChannels: ReadonlySet<string>,
68
+ ): boolean {
69
+ const description = `${channel.title ?? ""} ${channel.doc ?? ""}`;
70
+ return /(^|[^a-z0-9])kafka([^a-z0-9]|$)/i.test(description)
71
+ || handoffChannels.has(channel.address);
72
+ }
73
+
74
+ function read(): string {
75
+ try {
76
+ const value = localStorage.getItem(KAFKA_UI_KEY) ?? "";
77
+ return normalizeKafkaUiUrl(value) ?? "";
78
+ } catch {
79
+ return "";
80
+ }
81
+ }
82
+
83
+ function write(value: string): void {
84
+ try {
85
+ if (value) localStorage.setItem(KAFKA_UI_KEY, value);
86
+ else localStorage.removeItem(KAFKA_UI_KEY);
87
+ } catch {
88
+ /* private mode: keep the value for this session */
89
+ }
90
+ }
91
+
92
+ interface KafkaUiState {
93
+ url: string;
94
+ setUrl: (url: string) => void;
95
+ }
96
+
97
+ export const useKafkaUi = create<KafkaUiState>()((set) => ({
98
+ url: read(),
99
+ setUrl: (url) => {
100
+ const normalized = normalizeKafkaUiUrl(url);
101
+ if (normalized === null) return;
102
+ write(normalized);
103
+ set({ url: normalized });
104
+ },
105
+ }));
@@ -1,4 +1,5 @@
1
- import type { SetupInfo, SetupPhase, SetupProject, SetupRunStepStatus } from "./setup-info";
1
+ import type { SetupDiagnostic, SetupInfo, SetupPhase, SetupProject, SetupRunStepStatus } from "./setup-info";
2
+ import type { DjangoAggregateCandidates } from "./django-aggregates";
2
3
 
3
4
  const ROOT = `${import.meta.env.BASE_URL}__portolan`;
4
5
  const LOCAL_HEADER = { "Content-Type": "application/json", "X-Portolan-Local": "1" };
@@ -10,6 +11,7 @@ export interface Detection {
10
11
  candidates: string[];
11
12
  options: Record<string, unknown>;
12
13
  selected: boolean;
14
+ preview?: Array<{ file: string; fields: Record<string, string> }>;
13
15
  }
14
16
 
15
17
  export interface Discovery {
@@ -113,6 +115,7 @@ export interface ProjectTrialStep {
113
115
  fileCount: number;
114
116
  changedCount: number;
115
117
  warnings: string[];
118
+ diagnostics: SetupDiagnostic[];
116
119
  message?: string;
117
120
  }
118
121
 
@@ -121,6 +124,7 @@ export interface ProjectTrial {
121
124
  steps: ProjectTrialStep[];
122
125
  facts: ProjectTrialFact[];
123
126
  warnings: Array<{ plugin: string; message: string }>;
127
+ diagnostics: SetupDiagnostic[];
124
128
  generatedFiles: number;
125
129
  previewUrl?: string;
126
130
  previewError?: string;
@@ -130,7 +134,7 @@ export type RunEvent =
130
134
  | { type: "run-started"; at: string; runId: string; mode: "write" | "check" | "preview" | "project-preview" }
131
135
  | { type: "pipeline-ready"; at: string; stepCount: number }
132
136
  | { type: "step-started"; at: string; ordinal: number; phase: SetupPhase; plugin: string; input?: string; output: string }
133
- | { type: "step-finished"; at: string; ordinal: number; phase: SetupPhase; plugin: string; status: SetupRunStepStatus; durationMs: number; fileCount: number; changedCount: number; changes: Array<{ kind: "added" | "changed" | "removed"; path: string }>; files: string[]; warnings?: string[]; message?: string }
137
+ | { type: "step-finished"; at: string; ordinal: number; phase: SetupPhase; plugin: string; status: SetupRunStepStatus; durationMs: number; fileCount: number; changedCount: number; changes: Array<{ kind: "added" | "changed" | "removed"; path: string }>; files: string[]; warnings?: string[]; diagnostics?: SetupDiagnostic[]; message?: string }
134
138
  | { type: "preview-ready"; at: string; files: GeneratedFileDiff[]; totalFiles: number; truncated: boolean }
135
139
  | ({ type: "project-trial-ready"; at: string } & ProjectTrial)
136
140
  | { type: "run-finished"; at: string; status: string; durationMs?: number; message?: string }
@@ -178,6 +182,20 @@ export async function localStatus(): Promise<{ local: true; workspace: string; s
178
182
  return json("/status");
179
183
  }
180
184
 
185
+ export interface DjangoAggregateProposals {
186
+ revision: string;
187
+ stale: boolean;
188
+ proposals: Array<DjangoAggregateCandidates & { id: string; step: number; plugin: string; input: string; output: string; message: string }>;
189
+ }
190
+
191
+ export async function djangoAggregateProposals(): Promise<DjangoAggregateProposals> {
192
+ return json("/django-aggregates");
193
+ }
194
+
195
+ export async function saveDjangoAggregates(revision: string, selections: Array<{ id: string; model: string }>): Promise<{ saved: number }> {
196
+ return json("/django-aggregates", { method: "POST", headers: LOCAL_HEADER, body: JSON.stringify({ revision, selections }) });
197
+ }
198
+
181
199
  export async function previewDeliveryPreset(provider?: DeliveryProvider, features?: DeliveryFeatureId[]): Promise<DeliveryPreset> {
182
200
  const query = new URLSearchParams();
183
201
  if (provider) query.set("provider", provider);
@@ -174,6 +174,18 @@ describe("publicSetupFrom", () => {
174
174
  changedCount: 0,
175
175
  files: ["data/shop/catalog.json", "../../private-key"],
176
176
  warnings: ["shop.cart: internal/domain/errors has no struct called Errors", "", 42],
177
+ diagnostics: [{
178
+ plugin: "domain",
179
+ rule: "domain.missing-root",
180
+ severity: "warning",
181
+ action: "Name the root.",
182
+ message: "shop.cart: internal/domain/errors has no struct called Errors",
183
+ count: 1,
184
+ project: "shop",
185
+ phase: "extract",
186
+ suppressed: true,
187
+ suppressionReason: "Legacy package.",
188
+ }],
177
189
  error: "secret token",
178
190
  },
179
191
  ],
@@ -187,6 +199,11 @@ describe("publicSetupFrom", () => {
187
199
  status: "failed",
188
200
  files: ["data/shop/catalog.json"],
189
201
  warnings: ["shop.cart: internal/domain/errors has no struct called Errors"],
202
+ diagnostics: [{
203
+ rule: "domain.missing-root",
204
+ suppressed: true,
205
+ suppressionReason: "Legacy package.",
206
+ }],
190
207
  });
191
208
  expect(JSON.stringify(setup)).not.toContain("secret token");
192
209
  expect(JSON.stringify(setup)).not.toContain("private-key");
@@ -7,6 +7,8 @@
7
7
  * bundle.
8
8
  */
9
9
 
10
+ import { djangoAggregateCandidates, djangoAggregateMessage } from "./django-aggregates.ts";
11
+
10
12
  export type SetupPhase = "extract" | "verify" | "generate";
11
13
 
12
14
  export interface SetupProject {
@@ -59,6 +61,24 @@ export interface SetupRunStep extends SetupStep {
59
61
  * fix; a failed step's error stays out, because an error may quote anything.
60
62
  */
61
63
  warnings: string[];
64
+ diagnostics: SetupDiagnostic[];
65
+ }
66
+
67
+ export type SetupDiagnosticSeverity = "error" | "warning" | "info";
68
+
69
+ export interface SetupDiagnostic {
70
+ aggregateCandidates?: import("./django-aggregates").DjangoAggregateCandidates;
71
+ plugin: string;
72
+ rule: string;
73
+ severity: SetupDiagnosticSeverity;
74
+ action: string;
75
+ message: string;
76
+ count: number;
77
+ project: string;
78
+ phase: SetupPhase;
79
+ ref?: string;
80
+ suppressed: boolean;
81
+ suppressionReason?: string;
62
82
  }
63
83
 
64
84
  export interface SetupRun {
@@ -249,6 +269,10 @@ function runStepFrom(
249
269
  .filter((warning): warning is string => typeof warning === "string" && warning.trim() !== "")
250
270
  .slice(0, MAX_WARNINGS)
251
271
  .map((warning) => warning.slice(0, MAX_WARNING_LENGTH));
272
+ const diagnostics = array(item.diagnostics)
273
+ .map((diagnostic) => diagnosticFrom(diagnostic, item.plugin as string, item.phase as SetupPhase))
274
+ .filter((diagnostic): diagnostic is SetupDiagnostic => diagnostic !== null)
275
+ .slice(0, MAX_WARNINGS);
252
276
 
253
277
  return {
254
278
  ordinal: item.ordinal,
@@ -267,6 +291,40 @@ function runStepFrom(
267
291
  changedCount: item.changedCount,
268
292
  files,
269
293
  warnings,
294
+ diagnostics,
295
+ };
296
+ }
297
+
298
+ function diagnosticFrom(value: unknown, plugin: string, phase: SetupPhase): SetupDiagnostic | null {
299
+ const item = record(value);
300
+ if (
301
+ item.plugin !== plugin || item.phase !== phase ||
302
+ typeof item.rule !== "string" || !/^[a-z0-9]+(?:[.-][a-z0-9]+)*$/.test(item.rule) ||
303
+ !["error", "warning", "info"].includes(item.severity as string) ||
304
+ typeof item.action !== "string" || item.action.trim() === "" ||
305
+ typeof item.message !== "string" || item.message.trim() === "" ||
306
+ !wholeNumber(item.count) || item.count < 1 ||
307
+ typeof item.project !== "string" ||
308
+ typeof item.suppressed !== "boolean"
309
+ ) return null;
310
+ const ref = typeof item.ref === "string" && item.ref.trim() !== "" ? item.ref.slice(0, MAX_WARNING_LENGTH) : undefined;
311
+ const suppressionReason = typeof item.suppressionReason === "string" && item.suppressionReason.trim() !== ""
312
+ ? item.suppressionReason.slice(0, MAX_WARNING_LENGTH)
313
+ : undefined;
314
+ if (item.suppressed && !suppressionReason) return null;
315
+ return {
316
+ plugin,
317
+ rule: item.rule,
318
+ severity: item.severity as SetupDiagnosticSeverity,
319
+ action: item.action.slice(0, MAX_WARNING_LENGTH),
320
+ message: djangoAggregateMessage(item.message).slice(0, MAX_WARNING_LENGTH),
321
+ ...(djangoAggregateCandidates(item.message) ? { aggregateCandidates: djangoAggregateCandidates(item.message)! } : {}),
322
+ count: item.count,
323
+ project: item.project.slice(0, MAX_WARNING_LENGTH),
324
+ phase,
325
+ ...(ref ? { ref } : {}),
326
+ suppressed: item.suppressed,
327
+ ...(suppressionReason ? { suppressionReason } : {}),
270
328
  };
271
329
  }
272
330
 
@@ -0,0 +1,54 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { groupDiagnostics, groupWarnings, warningDiagnostic } from "./warnings";
3
+
4
+ describe("warning diagnostics", () => {
5
+ it("assigns a stable rule, severity and action to known warnings", () => {
6
+ const diagnostic = warningDiagnostic({
7
+ plugin: "openapi",
8
+ message: "aviacore-api: no operationId on 2 of 9 operations; listed by verb and path: POST /book, POST /cancel",
9
+ });
10
+
11
+ expect(diagnostic).toMatchObject({
12
+ plugin: "openapi",
13
+ rule: "openapi.missing-operation-id",
14
+ severity: "warning",
15
+ ref: "aviacore-api",
16
+ suppressed: false,
17
+ });
18
+ expect(diagnostic.action).toContain("operationId");
19
+ });
20
+
21
+ it("groups repetitions by plugin, rule and severity", () => {
22
+ const groups = groupWarnings([
23
+ { plugin: "openapi", message: "api.v1: no operationId on 1 of 3 operations; listed by verb and path: POST /book" },
24
+ { plugin: "openapi", message: "api.v2: no operationId on 1 of 3 operations; listed by verb and path: POST /cancel" },
25
+ { plugin: "wsdl", message: "b/schema.xsd: duplicate declaration CodeType and 2 more in namespace urn:common; the declarations in a/schema.xsd are used" },
26
+ ]);
27
+
28
+ expect(groups.map(({ plugin, rule, count }) => ({ plugin, rule, count }))).toEqual([
29
+ { plugin: "openapi", rule: "openapi.missing-operation-id", count: 2 },
30
+ { plugin: "wsdl", rule: "schema.duplicate-declaration", count: 1 },
31
+ ]);
32
+ });
33
+
34
+ it("marks read and parse failures as errors and expected absence as info", () => {
35
+ expect(warningDiagnostic({ plugin: "sql", message: "schema.sql could not be parsed" }).severity).toBe("error");
36
+ expect(warningDiagnostic({ plugin: "river", message: "root: no River jobs were found" }).severity).toBe("info");
37
+ });
38
+
39
+ it("groups persisted policy decisions without evaluating policies in the browser", () => {
40
+ const diagnostic = warningDiagnostic({ plugin: "openapi", message: "api: no operationId on 1 of 3 operations; listed by verb and path: POST /book" });
41
+ const groups = groupDiagnostics([{ ...diagnostic, suppressed: true, suppressionReason: "Owned upstream." }]);
42
+ expect(groups[0]).toMatchObject({ suppressed: true, suppressionReason: "Owned upstream.", count: 1 });
43
+ });
44
+
45
+ it("keeps unrelated unknown warnings in separate fallback rules", () => {
46
+ const groups = groupWarnings([
47
+ { plugin: "custom", message: "service: frobnicator needs attention" },
48
+ { plugin: "custom", message: "service: another limitation was observed" },
49
+ ]);
50
+
51
+ expect(groups).toHaveLength(2);
52
+ expect(groups.every((group) => group.rule.startsWith("plugin.custom.other-"))).toBe(true);
53
+ });
54
+ });