@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.
- package/README.md +23 -0
- package/catalog/enum_test.go +46 -0
- package/catalog/evidence_test.go +35 -0
- package/catalog/model.go +1066 -0
- package/catalog/roundtrip_test.go +203 -0
- package/catalog/via_test.go +38 -0
- package/cli/init.test.mjs +6 -1
- package/cli/portolan.mjs +8 -0
- package/cli/portolan.test.mjs +49 -0
- package/go.mod +14 -0
- package/go.sum +20 -0
- package/internal/gocall/README.md +19 -0
- package/internal/gocall/analyze.go +189 -0
- package/internal/gocall/analyze_test.go +107 -0
- package/internal/gohttp/analyze.go +2562 -0
- package/internal/gohttp/destination.go +373 -0
- package/internal/gohttp/endpoints.go +1067 -0
- package/internal/gohttp/roots.go +320 -0
- package/internal/gohttp/typed.go +96 -0
- package/internal/goscan/constants.go +85 -0
- package/internal/goscan/goscan_test.go +227 -0
- package/internal/goscan/index.go +629 -0
- package/internal/goscan/index_test.go +66 -0
- package/internal/goscan/names.go +52 -0
- package/internal/goscan/parse_test.go +11 -0
- package/internal/goscan/source.go +37 -0
- package/internal/goscan/tree.go +284 -0
- package/internal/goscan/types.go +99 -0
- package/internal/wsdl/ids.go +127 -0
- package/internal/wsdl/ids_test.go +21 -0
- package/internal/wsdl/model.go +70 -0
- package/internal/wsdl/parse.go +949 -0
- package/internal/wsdl/parse_test.go +170 -0
- package/package.json +22 -10
- package/plugin/describe.go +118 -0
- package/plugin/describe_test.go +114 -0
- package/plugin/protocol.go +141 -0
- package/plugin/schematest/schematest.go +126 -0
- package/plugins/README.md +114 -46
- package/plugins/cmd/portolan-http-clients/main.go +19 -0
- package/plugins/extract-celery/extract.py +0 -2
- package/plugins/extract-celery/extract_test.py +1 -1
- package/plugins/extract-django/README.md +39 -17
- package/plugins/extract-django/domain.py +28 -17
- package/plugins/extract-django/extract.py +21 -7
- package/plugins/extract-django/extract_test.py +55 -2
- package/plugins/extract-django/lifecycle.py +2 -0
- package/plugins/extract-django/operations.py +1 -1
- package/plugins/extract-django/routing_test.py +109 -1
- package/plugins/extract-django/store.py +1 -1
- package/plugins/extract-django/transport.py +101 -55
- package/plugins/extract-django/verbs.py +241 -0
- package/plugins/extract-go/README.md +47 -0
- package/plugins/extract-http-clients/describe.go +19 -0
- package/plugins/extract-http-clients/describe_test.go +11 -0
- package/plugins/extract-http-clients/extract.go +740 -0
- package/plugins/extract-http-clients/extract_test.go +1561 -0
- package/plugins/extract-http-clients/main.go +41 -0
- package/plugins/extract-java/build/org/portolan/extract/Extract.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Protocol$Builder.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Protocol$Input.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Protocol$Options.class +0 -0
- package/plugins/extract-python-kafka/README.md +6 -0
- package/plugins/extract-python-kafka/extract.py +2 -4
- package/plugins/extract-python-kafka/extract_test.py +18 -2
- package/plugins/extract-ts/extract.test.ts +2 -2
- package/plugins/extract-ts/extract.ts +4 -5
- package/plugins/extract-ts/graphql.test.ts +1 -1
- package/plugins/openapi/ids.go +261 -0
- package/plugins/openapi/ids_test.go +98 -0
- package/plugins/portolan-go.wasm +0 -0
- package/plugins/pyplugin/catalog.py +12 -1
- package/plugins/pyplugin/kafka.py +74 -3
- package/plugins/pyplugin/protocol.py +1 -5
- package/portolan.json +3 -2
- package/schema/portolan.schema.json +34 -0
- package/scripts/README.md +18 -12
- package/scripts/catalog-sources.mjs +6 -0
- package/scripts/delivery-presets.mjs +21 -11
- package/scripts/diff.mjs +5 -1
- package/scripts/django-aggregates.test.mjs +58 -0
- package/scripts/gen-likec4.mjs +79 -21
- package/scripts/gen-likec4.test.mjs +25 -2
- package/scripts/gen.mjs +118 -115
- package/scripts/go-discovery.test.mjs +30 -0
- package/scripts/history.mjs +186 -3
- package/scripts/history.test.mjs +1 -1
- package/scripts/host-plugins/fetch-git.mjs +77 -21
- package/scripts/host-plugins/fetch-git.test.mjs +62 -8
- package/scripts/local-api.mjs +71 -4
- package/scripts/local-api.test.mjs +63 -4
- package/scripts/local-discovery.mjs +82 -9
- package/scripts/manifest.mjs +5 -3
- package/scripts/manifest.test.mjs +24 -0
- package/scripts/output-diff.mjs +94 -0
- package/scripts/output-diff.test.mjs +36 -0
- package/scripts/package-smoke.mjs +62 -4
- package/scripts/plugin-host.mjs +22 -2
- package/scripts/plugin-host.test.mjs +9 -0
- package/scripts/plugin-wasm-worker.mjs +4 -1
- package/scripts/provenance.mjs +72 -0
- package/scripts/provenance.test.mjs +149 -0
- package/scripts/run-builtin.mjs +39 -5
- package/scripts/schema.mjs +29 -0
- package/scripts/warning-policy.mjs +161 -0
- package/scripts/warning-policy.test.mjs +56 -0
- package/src/app/Breadcrumbs.test.ts +4 -0
- package/src/app/Breadcrumbs.tsx +1 -0
- package/src/app/Sidebar.tsx +3 -3
- package/src/catalog-docs.test.ts +64 -0
- package/src/catalog-docs.ts +35 -0
- package/src/catalog-error.test.ts +15 -0
- package/src/catalog-model.ts +70 -6
- package/src/catalog-stores.test.ts +17 -0
- package/src/catalog-validation.ts +52 -2
- package/src/catalog.test.ts +13 -2
- package/src/chat/Starter.tsx +5 -11
- package/src/chat/tools.test.ts +27 -0
- package/src/chat/tools.ts +5 -9
- package/src/components/CatalogStamp.tsx +10 -8
- package/src/components/ChannelRows.messagepack.test.tsx +28 -0
- package/src/components/ChannelRows.test.tsx +54 -0
- package/src/components/ChannelRows.tsx +57 -10
- package/src/components/HTTPDestinationEvidence.test.tsx +23 -0
- package/src/components/HTTPDestinationEvidence.tsx +31 -0
- package/src/components/Integrations.tsx +1 -1
- package/src/components/LifecycleDiagram.tsx +28 -12
- package/src/components/MachineDocs.tsx +6 -5
- package/src/components/MethodRows.tsx +9 -2
- package/src/components/ProblemRow.tsx +4 -0
- package/src/components/RelationEvidence.test.tsx +14 -0
- package/src/components/RelationEvidence.tsx +53 -0
- package/src/components/WhatLinksHere.tsx +6 -4
- package/src/data.ts +25 -7
- package/src/enrich.test.ts +336 -6
- package/src/enrich.ts +206 -3
- package/src/flow/StepDetail.tsx +104 -54
- package/src/flow/answers.test.ts +18 -1
- package/src/flow/answers.ts +37 -8
- package/src/flow/evidence.test.ts +16 -0
- package/src/flow/evidence.ts +34 -0
- package/src/index.css +44 -0
- package/src/landing/DraggableReveal.tsx +3 -2
- package/src/landing/EvidencePipeline.tsx +105 -0
- package/src/landing/LandingPage.tsx +2 -59
- package/src/lib/backlinks.test.ts +16 -1
- package/src/lib/backlinks.ts +20 -0
- package/src/lib/catalog-diff.test.ts +18 -0
- package/src/lib/catalog-diff.ts +20 -2
- package/src/lib/derive.ts +1 -0
- package/src/lib/django-aggregates.d.mts +9 -0
- package/src/lib/django-aggregates.mjs +36 -0
- package/src/lib/django-aggregates.test.ts +29 -0
- package/src/lib/django-aggregates.ts +5 -0
- package/src/lib/kafka-ui.test.ts +87 -0
- package/src/lib/kafka-ui.ts +105 -0
- package/src/lib/local-api.ts +20 -2
- package/src/lib/setup-info.test.ts +17 -0
- package/src/lib/setup-info.ts +58 -0
- package/src/lib/warnings.test.ts +54 -0
- package/src/lib/warnings.ts +260 -0
- package/src/lib/wire-problems.test.ts +21 -0
- package/src/lib/wire-problems.ts +62 -1
- package/src/likec4/FlowView.tsx +2 -6
- package/src/likec4/flow-edges.test.ts +64 -1
- package/src/likec4/flow-edges.ts +43 -7
- package/src/likec4/view-index.ts +8 -2
- package/src/map/ContextMapGraph.tsx +76 -32
- package/src/merge.test.ts +23 -0
- package/src/merge.ts +33 -10
- package/src/pages/AggregatePage.tsx +8 -7
- package/src/pages/CatalogFailure.tsx +2 -2
- package/src/pages/ContextPage.tsx +6 -5
- package/src/pages/ServicePage.tsx +4 -3
- package/src/pages/Settings.tsx +200 -44
- package/src/pages/settings/DjangoAggregateChoices.tsx +79 -0
- package/src/pages/settings/IntegrationsSettings.tsx +117 -0
- package/src/routes.test.ts +2 -0
- package/src/routes.ts +2 -1
- package/src/selection/DetailPanel.tsx +61 -1
- package/src/virtual-provenance.d.ts +11 -0
- package/vite.config.ts +5 -0
- package/scripts/vendor-lock.mjs +0 -58
- 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 {
|
|
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", () => {
|
package/src/lib/backlinks.ts
CHANGED
|
@@ -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) => {
|
package/src/lib/catalog-diff.ts
CHANGED
|
@@ -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
|
@@ -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
|
+
}));
|
package/src/lib/local-api.ts
CHANGED
|
@@ -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");
|
package/src/lib/setup-info.ts
CHANGED
|
@@ -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
|
+
});
|