@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
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { djangoAggregateCandidates, djangoAggregateMessage } from "./django-aggregates";
|
|
2
|
+
import type { DjangoAggregateCandidates } from "./django-aggregates";
|
|
3
|
+
|
|
4
|
+
export type WarningSeverity = "error" | "warning" | "info";
|
|
5
|
+
|
|
6
|
+
export interface RawWarning {
|
|
7
|
+
plugin: string;
|
|
8
|
+
message: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface WarningDiagnostic extends RawWarning {
|
|
12
|
+
aggregateCandidates?: DjangoAggregateCandidates;
|
|
13
|
+
rule: string;
|
|
14
|
+
severity: WarningSeverity;
|
|
15
|
+
action: string;
|
|
16
|
+
count?: number;
|
|
17
|
+
project?: string;
|
|
18
|
+
phase?: string;
|
|
19
|
+
ref?: string;
|
|
20
|
+
suppressed: boolean;
|
|
21
|
+
suppressionReason?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface WarningGroup {
|
|
25
|
+
plugin: string;
|
|
26
|
+
rule: string;
|
|
27
|
+
severity: WarningSeverity;
|
|
28
|
+
action: string;
|
|
29
|
+
messages: WarningDiagnostic[];
|
|
30
|
+
count: number;
|
|
31
|
+
suppressed: boolean;
|
|
32
|
+
suppressionReason?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface RuleDefinition {
|
|
36
|
+
id: string;
|
|
37
|
+
matches: RegExp;
|
|
38
|
+
severity: WarningSeverity;
|
|
39
|
+
action: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Rules describe the recurring limitation, not the source-specific value in a
|
|
43
|
+
// message. Their ids are therefore stable enough to use in portolan.json.
|
|
44
|
+
const RULES: RuleDefinition[] = [
|
|
45
|
+
{
|
|
46
|
+
id: "openapi.missing-operation-id",
|
|
47
|
+
matches: /\bno operationId\b/i,
|
|
48
|
+
severity: "warning",
|
|
49
|
+
action: "Add stable operationId values to the OpenAPI operations.",
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
id: "schema.duplicate-declaration",
|
|
53
|
+
matches: /\bduplicate declaration\b/i,
|
|
54
|
+
severity: "warning",
|
|
55
|
+
action: "Remove or reconcile duplicate schema declarations; Portolan currently uses the first.",
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
id: "analysis.typed-fallback",
|
|
59
|
+
matches: /\btyped call graph unavailable\b/i,
|
|
60
|
+
severity: "warning",
|
|
61
|
+
action: "Run an extractor build with typed analysis support or inspect calls found by the syntax fallback.",
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
id: "catalog.unresolved-call",
|
|
65
|
+
matches: /\bcalls .+ which nothing in this catalog resolves\b/i,
|
|
66
|
+
severity: "warning",
|
|
67
|
+
action: "Add or correct the provider contract, then regenerate to resolve the call.",
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
id: "catalog.unmapped-proto-peer",
|
|
71
|
+
matches: /\bmanifest names no peer for that package\b/i,
|
|
72
|
+
severity: "warning",
|
|
73
|
+
action: "Map the protobuf package under peers, or declare it under externals.",
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
id: "flow.unresolved-step",
|
|
77
|
+
matches: /\bstep .+ is unresolved\b/i,
|
|
78
|
+
severity: "warning",
|
|
79
|
+
action: "Declare the referenced endpoint, message, or store so this flow step can be joined.",
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
id: "flow.unknown-port",
|
|
83
|
+
matches: /\bis neither a domain port nor a use case\b/i,
|
|
84
|
+
severity: "warning",
|
|
85
|
+
action: "Model this dependency as a domain port or use case, or accept that its calls stay outside the flow.",
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
id: "flow.unreached-event",
|
|
89
|
+
matches: /\bno flow reaches this event\b/i,
|
|
90
|
+
severity: "warning",
|
|
91
|
+
action: "Connect the event to its publisher flow or remove the stale event declaration.",
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
id: "flow.unknown-event",
|
|
95
|
+
matches: /\breacts to the message named .+ which no event .+ declares is called\b/i,
|
|
96
|
+
severity: "warning",
|
|
97
|
+
action: "Declare the event name used by the handler or correct the handler mapping.",
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
id: "django.invalid-aggregate-root",
|
|
101
|
+
matches: /\baggregates names .+ and no model there is called that\b/i,
|
|
102
|
+
severity: "warning",
|
|
103
|
+
action: "Choose an existing concrete model for the application's aggregates option.",
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
id: "django.ambiguous-aggregate-root",
|
|
107
|
+
matches: /\bmodels to choose from: name the root in the aggregates option\b/i,
|
|
108
|
+
severity: "warning",
|
|
109
|
+
action: "Choose the aggregate root explicitly in the django-domain aggregates option.",
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
id: "django.unknown-http-verb",
|
|
113
|
+
matches: /\bmounted as an HTTP view, but no HTTP verb is declared\b/i,
|
|
114
|
+
severity: "warning",
|
|
115
|
+
action: "Declare the accepted HTTP methods on the view or route.",
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
id: "river.missing-worker",
|
|
119
|
+
matches: /\bno registered Worker\b/i,
|
|
120
|
+
severity: "warning",
|
|
121
|
+
action: "Register the River worker in this component or remove the unmatched insert.",
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
id: "watermill.unresolved-topic",
|
|
125
|
+
matches: /\bWatermill .+ topic could not be resolved\b/i,
|
|
126
|
+
severity: "warning",
|
|
127
|
+
action: "Use a literal, constant, or configuration default for the Watermill topic.",
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
id: "messaging.unresolved-subject",
|
|
131
|
+
matches: /\bsubject of .+ could not be resolved\b/i,
|
|
132
|
+
severity: "warning",
|
|
133
|
+
action: "Use a literal, constant, configuration default, or visible caller argument for the subject.",
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
id: "store.external-migrations",
|
|
137
|
+
matches: /\bmigrations are applied from .+ whose schema is not in this tree\b/i,
|
|
138
|
+
severity: "warning",
|
|
139
|
+
action: "Vendor or expose the external migrations so their tables can be included in the store.",
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
id: "store.missing-foreign-table",
|
|
143
|
+
matches: /\bcolumn .+ references .+ which no migration here creates\b/i,
|
|
144
|
+
severity: "warning",
|
|
145
|
+
action: "Include the referenced table migration or correct the foreign-key target.",
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
id: "schema.unresolved-type",
|
|
149
|
+
matches: /\bis not declared in the protos read here\b/i,
|
|
150
|
+
severity: "warning",
|
|
151
|
+
action: "Include the imported protobuf declaration or map the type to an external schema.",
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
id: "source.offline-cache",
|
|
155
|
+
matches: /\bnot fetched \(offline\)\b/i,
|
|
156
|
+
severity: "info",
|
|
157
|
+
action: "Regenerate with network access when the vendored copy must be refreshed.",
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
id: "source.unpinned",
|
|
161
|
+
matches: /\bnot pinned\b/i,
|
|
162
|
+
severity: "warning",
|
|
163
|
+
action: "Pin the source to an immutable commit or schema version.",
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
id: "source.parse-failed",
|
|
167
|
+
matches: /\bcould not (?:be read|parse|be parsed|be encoded)\b/i,
|
|
168
|
+
severity: "error",
|
|
169
|
+
action: "Open the referenced source and fix the parse or read error.",
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
id: "extraction.no-match",
|
|
173
|
+
matches: /\b(?:no .+ (?:was|were) found|no .+ matched|declares no |no models in this application)\b/i,
|
|
174
|
+
severity: "info",
|
|
175
|
+
action: "Confirm this capability is absent, or point the extractor at the source that declares it.",
|
|
176
|
+
},
|
|
177
|
+
];
|
|
178
|
+
|
|
179
|
+
const FALLBACK_ACTION = "Inspect the referenced source and either fix the extraction gap or add a reviewed CEL policy with a reason.";
|
|
180
|
+
|
|
181
|
+
export function warningDiagnostic(
|
|
182
|
+
warning: RawWarning,
|
|
183
|
+
): WarningDiagnostic {
|
|
184
|
+
const definition = RULES.find((candidate) => candidate.matches.test(warning.message));
|
|
185
|
+
const rule = definition?.id ?? `plugin.${slug(warning.plugin)}.other-${fingerprint(warning.message)}`;
|
|
186
|
+
const ref = warningRef(warning.message);
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
...warning,
|
|
190
|
+
message: djangoAggregateMessage(warning.message),
|
|
191
|
+
...(djangoAggregateCandidates(warning.message) ? { aggregateCandidates: djangoAggregateCandidates(warning.message)! } : {}),
|
|
192
|
+
rule,
|
|
193
|
+
severity: definition?.severity ?? "warning",
|
|
194
|
+
action: definition?.action ?? FALLBACK_ACTION,
|
|
195
|
+
...(ref ? { ref } : {}),
|
|
196
|
+
suppressed: false,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function groupWarnings(
|
|
201
|
+
warnings: RawWarning[],
|
|
202
|
+
): WarningGroup[] {
|
|
203
|
+
return groupDiagnostics(warnings.map((warning) => warningDiagnostic(warning)));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function groupDiagnostics(diagnostics: WarningDiagnostic[]): WarningGroup[] {
|
|
207
|
+
const groups = new Map<string, WarningGroup>();
|
|
208
|
+
for (const warning of diagnostics) {
|
|
209
|
+
const key = `${warning.plugin}\0${warning.rule}\0${warning.severity}\0${warning.suppressed}`;
|
|
210
|
+
const existing = groups.get(key);
|
|
211
|
+
if (existing) {
|
|
212
|
+
existing.messages.push(warning);
|
|
213
|
+
existing.count += 1;
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
groups.set(key, {
|
|
217
|
+
plugin: warning.plugin,
|
|
218
|
+
rule: warning.rule,
|
|
219
|
+
severity: warning.severity,
|
|
220
|
+
action: warning.action,
|
|
221
|
+
messages: [warning],
|
|
222
|
+
count: 1,
|
|
223
|
+
suppressed: warning.suppressed,
|
|
224
|
+
...(warning.suppressionReason ? { suppressionReason: warning.suppressionReason } : {}),
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
const order: Record<WarningSeverity, number> = { error: 0, warning: 1, info: 2 };
|
|
228
|
+
return [...groups.values()].sort(
|
|
229
|
+
(left, right) => order[left.severity] - order[right.severity]
|
|
230
|
+
|| Number(left.suppressed) - Number(right.suppressed)
|
|
231
|
+
|| right.count - left.count
|
|
232
|
+
|| left.plugin.localeCompare(right.plugin)
|
|
233
|
+
|| left.rule.localeCompare(right.rule),
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function warningRef(message: string): string | undefined {
|
|
238
|
+
const match = message.match(/^(.+?):\s+(?=[A-Za-z/])/);
|
|
239
|
+
return match?.[1]?.trim() || undefined;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function slug(value: string): string {
|
|
243
|
+
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "unknown";
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function fingerprint(message: string): string {
|
|
247
|
+
const ref = warningRef(message);
|
|
248
|
+
const body = (ref ? message.slice(message.indexOf(":", ref.length) + 1) : message)
|
|
249
|
+
.toLowerCase()
|
|
250
|
+
.replace(/`[^`]*`|"[^"]*"|'[^']*'/g, "<value>")
|
|
251
|
+
.replace(/\b\d+\b/g, "#")
|
|
252
|
+
.replace(/\s+/g, " ")
|
|
253
|
+
.trim();
|
|
254
|
+
let hash = 0x811c9dc5;
|
|
255
|
+
for (let index = 0; index < body.length; index += 1) {
|
|
256
|
+
hash ^= body.charCodeAt(index);
|
|
257
|
+
hash = Math.imul(hash, 0x01000193);
|
|
258
|
+
}
|
|
259
|
+
return (hash >>> 0).toString(36);
|
|
260
|
+
}
|
|
@@ -260,4 +260,25 @@ describe("wireProblems", () => {
|
|
|
260
260
|
]);
|
|
261
261
|
expect(unresolved[0]?.severity).toBe("warning");
|
|
262
262
|
});
|
|
263
|
+
|
|
264
|
+
it("reports a MessagePack subscriber paired with a JSON publisher", () => {
|
|
265
|
+
const publisherChannel = channel("inventory.snapshots", "send inventory.Snapshot");
|
|
266
|
+
publisherChannel.messages[0]!.encoding = "json";
|
|
267
|
+
const subscriberChannel = channel("inventory.snapshots", "receive inventory.Snapshot");
|
|
268
|
+
subscriberChannel.messages[0]!.encoding = "msgpack";
|
|
269
|
+
const publisher = speaking(service("shop.inventory", []), publisherChannel);
|
|
270
|
+
const subscriber = speaking(service("shop.search", []), subscriberChannel);
|
|
271
|
+
|
|
272
|
+
const mismatch = found(catalogWith([publisher, subscriber])).find(
|
|
273
|
+
(problem) => problem.kind === "message-encoding",
|
|
274
|
+
);
|
|
275
|
+
expect(mismatch).toMatchObject({
|
|
276
|
+
severity: "error",
|
|
277
|
+
service: "shop.search",
|
|
278
|
+
peer: "shop.inventory",
|
|
279
|
+
});
|
|
280
|
+
expect(mismatch?.note).toContain("expects inventory.Snapshot");
|
|
281
|
+
expect(mismatch?.note).toContain("msgpack");
|
|
282
|
+
expect(mismatch?.note).toContain("json");
|
|
283
|
+
});
|
|
263
284
|
});
|
package/src/lib/wire-problems.ts
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
// merged catalog, for the same reason the store rule is: each extractor sees
|
|
20
20
|
// one repository and cannot know what another declares.
|
|
21
21
|
|
|
22
|
-
import type { Catalog, CatalogIndex, Event, Service } from "../catalog";
|
|
22
|
+
import type { Catalog, CatalogIndex, ChannelMessage, Event, Service } from "../catalog";
|
|
23
23
|
import type { Problem } from "./derive";
|
|
24
24
|
|
|
25
25
|
interface Publisher {
|
|
@@ -38,10 +38,71 @@ export function wireProblems(
|
|
|
38
38
|
return [
|
|
39
39
|
...sharedChannels(catalog),
|
|
40
40
|
...documentAgainstCode(catalog),
|
|
41
|
+
...messageEncodingMismatches(catalog),
|
|
41
42
|
...unresolvedSubscriptions(catalog, index),
|
|
42
43
|
];
|
|
43
44
|
}
|
|
44
45
|
|
|
46
|
+
interface MessageEndpoint {
|
|
47
|
+
context: string;
|
|
48
|
+
service: Service;
|
|
49
|
+
address: string;
|
|
50
|
+
source?: string;
|
|
51
|
+
message: ChannelMessage;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Producer and subscriber have both named a format, and those formats differ. */
|
|
55
|
+
function messageEncodingMismatches(catalog: Catalog): Problem[] {
|
|
56
|
+
const sends = new Map<string, MessageEndpoint[]>();
|
|
57
|
+
const receives: MessageEndpoint[] = [];
|
|
58
|
+
for (const context of catalog.contexts) {
|
|
59
|
+
for (const service of context.services) {
|
|
60
|
+
for (const channel of service.channels ?? []) {
|
|
61
|
+
for (const message of channel.messages) {
|
|
62
|
+
const endpoint = { context: context.id, service, address: channel.address, source: channel.source, message };
|
|
63
|
+
const key = `${channel.address}\u0000${message.name}`;
|
|
64
|
+
if (message.direction === "send") {
|
|
65
|
+
const publishers = sends.get(key) ?? [];
|
|
66
|
+
publishers.push(endpoint);
|
|
67
|
+
sends.set(key, publishers);
|
|
68
|
+
} else {
|
|
69
|
+
receives.push(endpoint);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const out: Problem[] = [];
|
|
77
|
+
for (const receiver of receives) {
|
|
78
|
+
const expected = wireFormat(receiver.message);
|
|
79
|
+
if (!expected) continue;
|
|
80
|
+
const key = `${receiver.address}\u0000${receiver.message.name}`;
|
|
81
|
+
for (const publisher of sends.get(key) ?? []) {
|
|
82
|
+
const actual = wireFormat(publisher.message);
|
|
83
|
+
if (!actual || actual === expected) continue;
|
|
84
|
+
out.push({
|
|
85
|
+
kind: "message-encoding",
|
|
86
|
+
severity: "error",
|
|
87
|
+
context: receiver.context,
|
|
88
|
+
service: receiver.service.id,
|
|
89
|
+
id: receiver.service.id,
|
|
90
|
+
peer: publisher.service.id,
|
|
91
|
+
note: `${receiver.service.id} expects ${receiver.message.name} on ${receiver.address} as ${expected}, but ${publisher.service.id} sends it as ${actual}.`,
|
|
92
|
+
source: receiver.source,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function wireFormat(message: ChannelMessage): string {
|
|
100
|
+
if (message.encoding) return message.encoding.trim().toLowerCase();
|
|
101
|
+
const contentType = message.contentType?.split(";", 1)[0]?.trim().toLowerCase() ?? "";
|
|
102
|
+
if (contentType.includes("msgpack") || contentType.includes("messagepack")) return "msgpack";
|
|
103
|
+
return contentType;
|
|
104
|
+
}
|
|
105
|
+
|
|
45
106
|
/**
|
|
46
107
|
* Every service that publishes on a channel another service publishes on
|
|
47
108
|
* too: one row per service per such channel, pointing at the other side.
|
package/src/likec4/FlowView.tsx
CHANGED
|
@@ -100,18 +100,14 @@ export function FlowView({
|
|
|
100
100
|
|
|
101
101
|
const highlightEdges = useMemo(
|
|
102
102
|
() =>
|
|
103
|
-
marked
|
|
104
|
-
.map((stepId) => pairing.edgeOf.get(stepId))
|
|
105
|
-
.filter((id): id is string => id !== undefined),
|
|
103
|
+
marked.flatMap((stepId) => pairing.edgesOf.get(stepId) ?? []),
|
|
106
104
|
[marked, pairing],
|
|
107
105
|
);
|
|
108
106
|
|
|
109
107
|
const focusedPathEdges = useMemo(
|
|
110
108
|
() =>
|
|
111
109
|
pathSteps
|
|
112
|
-
? pathSteps
|
|
113
|
-
.map((stepId) => pairing.edgeOf.get(stepId))
|
|
114
|
-
.filter((id): id is string => id !== undefined)
|
|
110
|
+
? pathSteps.flatMap((stepId) => pairing.edgesOf.get(stepId) ?? [])
|
|
115
111
|
: null,
|
|
116
112
|
[pathSteps, pairing],
|
|
117
113
|
);
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import { catalog } from "../data";
|
|
3
|
+
import type { Flow } from "../catalog";
|
|
3
4
|
import { walkSteps } from "../catalog";
|
|
4
5
|
import { hiddenStepIds } from "../flow/cross-context";
|
|
5
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
drawnEdgeStepIds,
|
|
8
|
+
drawnStepIds,
|
|
9
|
+
pairEdgesToSteps,
|
|
10
|
+
} from "./flow-edges";
|
|
6
11
|
|
|
7
12
|
describe("pairEdgesToSteps", () => {
|
|
8
13
|
it("pairs by position, both ways", () => {
|
|
@@ -14,6 +19,16 @@ describe("pairEdgesToSteps", () => {
|
|
|
14
19
|
expect(pairing.edgeOf.get("s3")).toBe("step-02:par.02");
|
|
15
20
|
});
|
|
16
21
|
|
|
22
|
+
it("pairs a request and its generated response to the same catalog step", () => {
|
|
23
|
+
const pairing = pairEdgesToSteps(
|
|
24
|
+
["step-01", "step-02"],
|
|
25
|
+
["request", "request"],
|
|
26
|
+
);
|
|
27
|
+
expect(pairing.stepOf.get("step-02")).toBe("request");
|
|
28
|
+
expect(pairing.edgeOf.get("request")).toBe("step-01");
|
|
29
|
+
expect(pairing.edgesOf.get("request")).toEqual(["step-01", "step-02"]);
|
|
30
|
+
});
|
|
31
|
+
|
|
17
32
|
/**
|
|
18
33
|
* A length mismatch means the generator and the view have drifted. Guessing
|
|
19
34
|
* would light the wrong arrow, which is worse than lighting none, so the
|
|
@@ -23,6 +38,7 @@ describe("pairEdgesToSteps", () => {
|
|
|
23
38
|
const pairing = pairEdgesToSteps(["step-01"], ["s1", "s2"]);
|
|
24
39
|
expect(pairing.stepOf.size).toBe(0);
|
|
25
40
|
expect(pairing.edgeOf.size).toBe(0);
|
|
41
|
+
expect(pairing.edgesOf.size).toBe(0);
|
|
26
42
|
});
|
|
27
43
|
});
|
|
28
44
|
|
|
@@ -49,3 +65,50 @@ describe("drawnStepIds", () => {
|
|
|
49
65
|
}
|
|
50
66
|
});
|
|
51
67
|
});
|
|
68
|
+
|
|
69
|
+
describe("drawnEdgeStepIds", () => {
|
|
70
|
+
it("returns nested RPC responses in place and the actor response at the end", () => {
|
|
71
|
+
const flow: Flow = {
|
|
72
|
+
id: "flow.checkout",
|
|
73
|
+
slug: "checkout",
|
|
74
|
+
name: "Checkout",
|
|
75
|
+
summary: "",
|
|
76
|
+
owner: "shop",
|
|
77
|
+
participants: [
|
|
78
|
+
{ id: "client", kind: "actor", context: null },
|
|
79
|
+
{ id: "shop.cart", kind: "service", context: "shop" },
|
|
80
|
+
{ id: "auth.auth", kind: "service", context: "auth" },
|
|
81
|
+
],
|
|
82
|
+
steps: [
|
|
83
|
+
{
|
|
84
|
+
type: "step",
|
|
85
|
+
id: "root",
|
|
86
|
+
from: "client",
|
|
87
|
+
to: "shop.cart",
|
|
88
|
+
kind: "rpc",
|
|
89
|
+
status: "declared",
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
type: "step",
|
|
93
|
+
id: "nested",
|
|
94
|
+
from: "shop.cart",
|
|
95
|
+
to: "auth.auth",
|
|
96
|
+
kind: "rpc",
|
|
97
|
+
status: "declared",
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
type: "step",
|
|
101
|
+
id: "done",
|
|
102
|
+
from: "shop.cart",
|
|
103
|
+
to: "client",
|
|
104
|
+
kind: "event",
|
|
105
|
+
status: "declared",
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
expect(
|
|
111
|
+
drawnEdgeStepIds(flow, false, new Set(["root", "nested"])),
|
|
112
|
+
).toEqual(["root", "nested", "nested", "done", "root"]);
|
|
113
|
+
});
|
|
114
|
+
});
|
package/src/likec4/flow-edges.ts
CHANGED
|
@@ -3,10 +3,9 @@
|
|
|
3
3
|
// id across, so the two are paired by position.
|
|
4
4
|
//
|
|
5
5
|
// That is sound because it is the same walk twice: the generator emits steps in
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
// abandoned rather than guessed at, and the diagram simply stops highlighting.
|
|
6
|
+
// catalog order and this module inserts the same contract-response edges in the
|
|
7
|
+
// same places. If the two lists ever differ in length the pairing is abandoned
|
|
8
|
+
// rather than guessed at, and the diagram simply stops highlighting.
|
|
10
9
|
|
|
11
10
|
import type { Flow } from "../catalog";
|
|
12
11
|
import { walkSteps } from "../catalog";
|
|
@@ -15,13 +14,16 @@ import { hiddenStepIds } from "../flow/cross-context";
|
|
|
15
14
|
export interface EdgeStepPairing {
|
|
16
15
|
/** LikeC4 edge id -> catalog step id */
|
|
17
16
|
stepOf: Map<string, string>;
|
|
18
|
-
/** catalog step id -> LikeC4 edge id */
|
|
17
|
+
/** catalog step id -> its primary LikeC4 edge id */
|
|
19
18
|
edgeOf: Map<string, string>;
|
|
19
|
+
/** catalog step id -> every edge it draws, including a contract response */
|
|
20
|
+
edgesOf: Map<string, string[]>;
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
export const EMPTY_PAIRING: EdgeStepPairing = {
|
|
23
24
|
stepOf: new Map(),
|
|
24
25
|
edgeOf: new Map(),
|
|
26
|
+
edgesOf: new Map(),
|
|
25
27
|
};
|
|
26
28
|
|
|
27
29
|
export function pairEdgesToSteps(
|
|
@@ -31,13 +33,17 @@ export function pairEdgesToSteps(
|
|
|
31
33
|
if (edgeIds.length !== stepIds.length) return EMPTY_PAIRING;
|
|
32
34
|
const stepOf = new Map<string, string>();
|
|
33
35
|
const edgeOf = new Map<string, string>();
|
|
36
|
+
const edgesOf = new Map<string, string[]>();
|
|
34
37
|
edgeIds.forEach((edgeId, i) => {
|
|
35
38
|
const stepId = stepIds[i];
|
|
36
39
|
if (stepId === undefined) return;
|
|
37
40
|
stepOf.set(edgeId, stepId);
|
|
38
|
-
edgeOf.set(stepId, edgeId);
|
|
41
|
+
if (!edgeOf.has(stepId)) edgeOf.set(stepId, edgeId);
|
|
42
|
+
const edges = edgesOf.get(stepId) ?? [];
|
|
43
|
+
edges.push(edgeId);
|
|
44
|
+
edgesOf.set(stepId, edges);
|
|
39
45
|
});
|
|
40
|
-
return { stepOf, edgeOf };
|
|
46
|
+
return { stepOf, edgeOf, edgesOf };
|
|
41
47
|
}
|
|
42
48
|
|
|
43
49
|
/**
|
|
@@ -87,3 +93,33 @@ export function drawnStepIds(flow: Flow, crossOnly: boolean): string[] {
|
|
|
87
93
|
const hidden = hiddenStepIds(flow);
|
|
88
94
|
return steps.filter((s) => !hidden.has(s.id)).map((s) => s.id);
|
|
89
95
|
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* One step id per generated edge, including contract responses synthesized by
|
|
99
|
+
* the LikeC4 generator. Nested RPCs return immediately; the actor request that
|
|
100
|
+
* opened the flow returns after its final step.
|
|
101
|
+
*/
|
|
102
|
+
export function drawnEdgeStepIds(
|
|
103
|
+
flow: Flow,
|
|
104
|
+
crossOnly: boolean,
|
|
105
|
+
responseStepIds: ReadonlySet<string>,
|
|
106
|
+
): string[] {
|
|
107
|
+
const steps = walkSteps(flow.steps);
|
|
108
|
+
const actorIds = new Set(
|
|
109
|
+
flow.participants.filter((p) => p.kind === "actor").map((p) => p.id),
|
|
110
|
+
);
|
|
111
|
+
const deferred = steps.find(
|
|
112
|
+
(step) =>
|
|
113
|
+
step.kind === "rpc" &&
|
|
114
|
+
actorIds.has(step.from) &&
|
|
115
|
+
responseStepIds.has(step.id),
|
|
116
|
+
)?.id;
|
|
117
|
+
const drawn = drawnStepIds(flow, crossOnly);
|
|
118
|
+
const out: string[] = [];
|
|
119
|
+
for (const stepId of drawn) {
|
|
120
|
+
out.push(stepId);
|
|
121
|
+
if (stepId !== deferred && responseStepIds.has(stepId)) out.push(stepId);
|
|
122
|
+
}
|
|
123
|
+
if (deferred && drawn.includes(deferred)) out.push(deferred);
|
|
124
|
+
return out;
|
|
125
|
+
}
|
package/src/likec4/view-index.ts
CHANGED
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
import { pickViewBounds } from "likec4/react";
|
|
9
9
|
import { likec4model } from "./generated";
|
|
10
10
|
import type { Flow } from "../catalog";
|
|
11
|
-
import {
|
|
11
|
+
import { index } from "../data";
|
|
12
|
+
import { flowAnswers } from "../flow/answers";
|
|
13
|
+
import { drawnEdgeStepIds, pairEdgesToSteps } from "./flow-edges";
|
|
12
14
|
import type { EdgeStepPairing } from "./flow-edges";
|
|
13
15
|
import { EMPTY_PAIRING } from "./flow-edges";
|
|
14
16
|
import { flowCrossViewId, flowViewId } from "./ids";
|
|
@@ -70,8 +72,12 @@ export function flowPairing(flow: Flow, crossOnly: boolean): EdgeStepPairing {
|
|
|
70
72
|
const viewId = crossOnly ? flowCrossViewId(flow) : flowViewId(flow);
|
|
71
73
|
const cached = pairings.get(viewId);
|
|
72
74
|
if (cached) return cached;
|
|
75
|
+
const responseStepIds = new Set(flowAnswers(index, flow).keys());
|
|
73
76
|
const pairing = shapeOf(viewId).edgeIds.length
|
|
74
|
-
? pairEdgesToSteps(
|
|
77
|
+
? pairEdgesToSteps(
|
|
78
|
+
shapeOf(viewId).edgeIds,
|
|
79
|
+
drawnEdgeStepIds(flow, crossOnly, responseStepIds),
|
|
80
|
+
)
|
|
75
81
|
: EMPTY_PAIRING;
|
|
76
82
|
pairings.set(viewId, pairing);
|
|
77
83
|
return pairing;
|