@shortlink-org/portolan 0.2.4 → 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 +109 -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/extract.py +0 -2
- package/plugins/extract-python-kafka/extract_test.py +1 -1
- 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/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 +1 -1
- 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/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 +48 -5
- package/src/catalog-validation.ts +9 -0
- 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/HTTPDestinationEvidence.test.tsx +23 -0
- package/src/components/HTTPDestinationEvidence.tsx +31 -0
- package/src/components/Integrations.tsx +1 -1
- package/src/components/MachineDocs.tsx +6 -5
- package/src/components/MethodRows.tsx +9 -2
- package/src/components/RelationEvidence.test.tsx +14 -0
- package/src/components/RelationEvidence.tsx +53 -0
- package/src/data.ts +25 -7
- package/src/enrich.test.ts +332 -1
- package/src/enrich.ts +206 -3
- package/src/flow/StepDetail.tsx +6 -0
- 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/catalog-diff.ts +1 -1
- 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/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/map/ContextMapGraph.tsx +76 -32
- package/src/merge.ts +16 -9
- package/src/pages/AggregatePage.tsx +8 -7
- package/src/pages/ContextPage.tsx +6 -5
- package/src/pages/ServicePage.tsx +4 -3
- package/src/pages/Settings.tsx +189 -41
- package/src/pages/settings/DjangoAggregateChoices.tsx +79 -0
- package/src/selection/DetailPanel.tsx +15 -0
- 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
|
+
}
|
|
@@ -16,7 +16,14 @@
|
|
|
16
16
|
// status among everything the line carries: a relationship is known no better
|
|
17
17
|
// than its least-known hop.
|
|
18
18
|
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
useCallback,
|
|
21
|
+
useEffect,
|
|
22
|
+
useLayoutEffect,
|
|
23
|
+
useMemo,
|
|
24
|
+
useRef,
|
|
25
|
+
useState,
|
|
26
|
+
} from "react";
|
|
20
27
|
import { useNavigate } from "react-router";
|
|
21
28
|
import {
|
|
22
29
|
Background,
|
|
@@ -79,6 +86,36 @@ export interface ContextMapGraphProps {
|
|
|
79
86
|
inactiveNodeOpacity?: number;
|
|
80
87
|
}
|
|
81
88
|
|
|
89
|
+
// React Flow reads its parent box during mount. The page shell and embedded
|
|
90
|
+
// hero can both briefly be 0×0 while their layout settles, so keep the flow
|
|
91
|
+
// out of that box until a real size has been observed.
|
|
92
|
+
function useMeasuredCanvas() {
|
|
93
|
+
const canvasRef = useRef<HTMLDivElement>(null);
|
|
94
|
+
const [hasSize, setHasSize] = useState(false);
|
|
95
|
+
|
|
96
|
+
useLayoutEffect(() => {
|
|
97
|
+
const canvas = canvasRef.current;
|
|
98
|
+
if (!canvas) return;
|
|
99
|
+
|
|
100
|
+
const measure = (): void => {
|
|
101
|
+
const next = canvas.clientWidth > 0 && canvas.clientHeight > 0;
|
|
102
|
+
setHasSize((current) => (current === next ? current : next));
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
measure();
|
|
106
|
+
if (typeof ResizeObserver === "undefined") {
|
|
107
|
+
window.addEventListener("resize", measure);
|
|
108
|
+
return () => window.removeEventListener("resize", measure);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const observer = new ResizeObserver(measure);
|
|
112
|
+
observer.observe(canvas);
|
|
113
|
+
return () => observer.disconnect();
|
|
114
|
+
}, []);
|
|
115
|
+
|
|
116
|
+
return { canvasRef, hasSize };
|
|
117
|
+
}
|
|
118
|
+
|
|
82
119
|
export function ContextMapGraph({
|
|
83
120
|
catalog,
|
|
84
121
|
relations,
|
|
@@ -97,6 +134,7 @@ export function ContextMapGraph({
|
|
|
97
134
|
edges: [],
|
|
98
135
|
ready: false,
|
|
99
136
|
});
|
|
137
|
+
const { canvasRef, hasSize } = useMeasuredCanvas();
|
|
100
138
|
|
|
101
139
|
// One entry per pair the catalog has anything to say about. `back` is what
|
|
102
140
|
// turns an arrow into a double-headed one; elk only ever sees a -> b, so the
|
|
@@ -257,37 +295,43 @@ export function ContextMapGraph({
|
|
|
257
295
|
);
|
|
258
296
|
|
|
259
297
|
return (
|
|
260
|
-
<div
|
|
261
|
-
{
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
298
|
+
<div
|
|
299
|
+
ref={canvasRef}
|
|
300
|
+
className="canvas-motion relative h-full w-full"
|
|
301
|
+
>
|
|
302
|
+
{drawn.ready && hasSize ? (
|
|
303
|
+
<ReactFlow
|
|
304
|
+
nodes={shownNodes}
|
|
305
|
+
edges={shownEdges}
|
|
306
|
+
nodeTypes={contextNodeTypes}
|
|
307
|
+
edgeTypes={mapEdgeTypes}
|
|
308
|
+
onNodeClick={onNodeClick}
|
|
309
|
+
onNodeDoubleClick={onNodeDoubleClick}
|
|
310
|
+
onPaneClick={() => clear("diagram")}
|
|
311
|
+
nodesDraggable={false}
|
|
312
|
+
nodesConnectable={false}
|
|
313
|
+
elementsSelectable
|
|
314
|
+
proOptions={{ hideAttribution: true }}
|
|
315
|
+
fitView
|
|
316
|
+
fitViewOptions={{ padding: compactLayout ? 0.06 : 0.16 }}
|
|
317
|
+
zoomOnScroll={zoomOnScroll}
|
|
318
|
+
preventScrolling={zoomOnScroll}
|
|
319
|
+
minZoom={0.2}
|
|
320
|
+
maxZoom={1.6}
|
|
321
|
+
key={fitKey}
|
|
322
|
+
>
|
|
323
|
+
<Background gap={20} size={2} />
|
|
324
|
+
{/* The map has no modes to switch, so its only control is the way
|
|
325
|
+
out: the drawing as a file, for the page that explains it. */}
|
|
326
|
+
{showExport ? (
|
|
327
|
+
<Panel position="top-right">
|
|
328
|
+
<ExportSeg name="context-map" />
|
|
329
|
+
</Panel>
|
|
330
|
+
) : null}
|
|
331
|
+
</ReactFlow>
|
|
332
|
+
) : (
|
|
333
|
+
<DiagramSkeleton />
|
|
334
|
+
)}
|
|
291
335
|
</div>
|
|
292
336
|
);
|
|
293
337
|
}
|
package/src/merge.ts
CHANGED
|
@@ -42,13 +42,14 @@ import type {
|
|
|
42
42
|
* A catalog as a FILE may carry it, which is not quite a catalog as a reader
|
|
43
43
|
* gets one: the two stamps are optional here and required downstream.
|
|
44
44
|
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
45
|
+
* A fragment carries no stamp of its own (portolan.0010). When it last
|
|
46
|
+
* changed, and in which commit, is what the history of the checkout says,
|
|
47
|
+
* and whoever reads the file asks the history and hands the answer over
|
|
48
|
+
* beside it. A file may still say something - a fragment written before
|
|
49
|
+
* portolan.0010 does - and that is taken only when nobody read the history.
|
|
50
|
+
* A provenance typed in by hand is a claim no process keeps true, so a source
|
|
51
|
+
* that says nothing and was read outside any history is stamped by nobody,
|
|
52
|
+
* and saying nothing is treated as what it is - an absence of evidence, which
|
|
52
53
|
* drops out of the merge rather than voting in it.
|
|
53
54
|
*/
|
|
54
55
|
export type SourceCatalog = Omit<Catalog, "generatedAt" | "commit"> &
|
|
@@ -59,6 +60,12 @@ export interface CatalogSource {
|
|
|
59
60
|
/** Where it was read from, as a reader would type it. */
|
|
60
61
|
path: string;
|
|
61
62
|
catalog: SourceCatalog;
|
|
63
|
+
/**
|
|
64
|
+
* When the file last changed and in which commit, as the reader found out
|
|
65
|
+
* from the history of the checkout (portolan.0010). Left out when there
|
|
66
|
+
* was no history to ask, and the file then speaks for itself.
|
|
67
|
+
*/
|
|
68
|
+
stamp?: Pick<SourceStamp, "generatedAt" | "commit">;
|
|
62
69
|
}
|
|
63
70
|
|
|
64
71
|
/**
|
|
@@ -120,8 +127,8 @@ export function mergeCatalogs(sources: CatalogSource[]): MergeResult {
|
|
|
120
127
|
|
|
121
128
|
const stamps: SourceStamp[] = ordered.map((source) => ({
|
|
122
129
|
path: source.path,
|
|
123
|
-
generatedAt: source.catalog.generatedAt ?? "",
|
|
124
|
-
commit: source.catalog.commit ?? "",
|
|
130
|
+
generatedAt: source.stamp?.generatedAt ?? source.catalog.generatedAt ?? "",
|
|
131
|
+
commit: source.stamp?.commit ?? source.catalog.commit ?? "",
|
|
125
132
|
}));
|
|
126
133
|
|
|
127
134
|
for (const { path, catalog } of ordered) {
|
|
@@ -77,14 +77,14 @@ function BuildingBlocks({
|
|
|
77
77
|
</div>
|
|
78
78
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 px-4 py-3">
|
|
79
79
|
<span className="mono flex items-center gap-1.5 text-muted">
|
|
80
|
-
root
|
|
80
|
+
{aggregate.kind === "model-group" ? "aggregate boundary" : "root"}
|
|
81
81
|
<KindIcon kind="entity" />
|
|
82
82
|
{rootTo ? (
|
|
83
83
|
<Link to={rootTo} className="text-ink hover:underline">
|
|
84
84
|
{aggregate.root}
|
|
85
85
|
</Link>
|
|
86
86
|
) : (
|
|
87
|
-
<span className="text-ink">{aggregate.root}</span>
|
|
87
|
+
<span className="text-ink">{aggregate.kind === "model-group" ? "not specified" : aggregate.root}</span>
|
|
88
88
|
)}
|
|
89
89
|
</span>
|
|
90
90
|
<span aria-hidden className="h-4 w-px bg-line-strong" />
|
|
@@ -385,7 +385,7 @@ export function AggregatePage() {
|
|
|
385
385
|
<PageHeader
|
|
386
386
|
kind={
|
|
387
387
|
<>
|
|
388
|
-
aggregate ·{" "}
|
|
388
|
+
{aggregate.kind === "model-group" ? "model group" : "aggregate"} ·{" "}
|
|
389
389
|
<Link
|
|
390
390
|
to={paths.service(context.id, service.slug)}
|
|
391
391
|
className="rounded-control hover:text-ink hover:underline"
|
|
@@ -410,6 +410,7 @@ export function AggregatePage() {
|
|
|
410
410
|
/>
|
|
411
411
|
|
|
412
412
|
<Markdown mermaid>{aggregate.readme}</Markdown>
|
|
413
|
+
{aggregate.kind === "model-group" ? <p className="mb-section text-muted">Models discovered in this application. Their relationships are extracted from the source; aggregate boundaries have not been specified.</p> : null}
|
|
413
414
|
|
|
414
415
|
<div
|
|
415
416
|
className="mt-section max-w-prose"
|
|
@@ -583,14 +584,14 @@ export function AggregatePage() {
|
|
|
583
584
|
anchor={AGGREGATE_ANCHOR.commands}
|
|
584
585
|
right={
|
|
585
586
|
<span>
|
|
586
|
-
they change the aggregate — one row lock each
|
|
587
|
+
{aggregate.kind === "model-group" ? "write operations discovered in this application" : "they change the aggregate — one row lock each"}
|
|
587
588
|
</span>
|
|
588
589
|
}
|
|
589
590
|
>
|
|
590
591
|
Commands
|
|
591
592
|
</SectionTitle>
|
|
592
593
|
{commands.length === 0 ? (
|
|
593
|
-
<Empty>nothing changes this aggregate from outside</Empty>
|
|
594
|
+
<Empty>{aggregate.kind === "model-group" ? "no application commands discovered" : "nothing changes this aggregate from outside"}</Empty>
|
|
594
595
|
) : null}
|
|
595
596
|
<OperationList kind="command" operations={commands} service={service} />
|
|
596
597
|
</div>
|
|
@@ -606,7 +607,7 @@ export function AggregatePage() {
|
|
|
606
607
|
Queries
|
|
607
608
|
</SectionTitle>
|
|
608
609
|
{queries.length === 0 ? (
|
|
609
|
-
<Empty>nothing reads this aggregate by name</Empty>
|
|
610
|
+
<Empty>{aggregate.kind === "model-group" ? "no application queries discovered" : "nothing reads this aggregate by name"}</Empty>
|
|
610
611
|
) : null}
|
|
611
612
|
<OperationList kind="query" operations={queries} service={service} />
|
|
612
613
|
</div>
|
|
@@ -750,7 +751,7 @@ export function AggregatePage() {
|
|
|
750
751
|
/>
|
|
751
752
|
</div>
|
|
752
753
|
|
|
753
|
-
<Toc items={toc} label="Sections of this aggregate" title="Outline" />
|
|
754
|
+
<Toc items={toc} label={aggregate.kind === "model-group" ? "Sections of this model group" : "Sections of this aggregate"} title="Outline" />
|
|
754
755
|
</div>
|
|
755
756
|
</div>
|
|
756
757
|
);
|
|
@@ -37,6 +37,7 @@ export function ContextPage() {
|
|
|
37
37
|
);
|
|
38
38
|
const neutral = groupKind(context) !== "bounded-context";
|
|
39
39
|
const showDomain = !neutral || aggregates.length > 0;
|
|
40
|
+
const hasModelGroups = aggregates.some(({ aggregate }) => aggregate.kind === "model-group");
|
|
40
41
|
const toc: TocItem[] = [
|
|
41
42
|
{ id: CONTEXT_ANCHOR.services, label: neutral ? "Components" : "Services" },
|
|
42
43
|
...(showDomain
|
|
@@ -86,7 +87,7 @@ export function ContextPage() {
|
|
|
86
87
|
{showDomain ? (
|
|
87
88
|
<>
|
|
88
89
|
<a href={`#${CONTEXT_ANCHOR.aggregates}`} className="rounded-control hover:text-ink">
|
|
89
|
-
<span className="tnum">{stats.aggregates}</span>{" "}{plural(stats.aggregates, "aggregate")}
|
|
90
|
+
<span className="tnum">{stats.aggregates}</span>{" "}{plural(stats.aggregates, hasModelGroups ? "group" : "aggregate")}
|
|
90
91
|
</a>
|
|
91
92
|
<a href={`#${CONTEXT_ANCHOR.events}`} className="rounded-control hover:text-ink">
|
|
92
93
|
<span className="tnum">{stats.events}</span>{" "}{plural(stats.events, "event")}
|
|
@@ -173,7 +174,7 @@ export function ContextPage() {
|
|
|
173
174
|
<div className="mono mt-4 flex flex-wrap gap-x-4 text-muted">
|
|
174
175
|
{service.aggregates.length > 0 || componentKind(service) === "service" ? (
|
|
175
176
|
<Link to={`${paths.service(context.id, service.slug)}#svc-aggregates`} className="rounded-control hover:text-ink">
|
|
176
|
-
<span className="tnum">{service.aggregates.length}</span>{" "}{plural(service.aggregates.length, "aggregate")}
|
|
177
|
+
<span className="tnum">{service.aggregates.length}</span>{" "}{plural(service.aggregates.length, service.aggregates.some((aggregate) => aggregate.kind === "model-group") ? "group" : "aggregate")}
|
|
177
178
|
</Link>
|
|
178
179
|
) : null}
|
|
179
180
|
<Link
|
|
@@ -234,11 +235,11 @@ export function ContextPage() {
|
|
|
234
235
|
anchor={CONTEXT_ANCHOR.aggregates}
|
|
235
236
|
right={
|
|
236
237
|
<span>
|
|
237
|
-
every aggregate this domain owns, whichever service holds it
|
|
238
|
+
{hasModelGroups ? "models grouped by their source application or aggregate" : "every aggregate this domain owns, whichever service holds it"}
|
|
238
239
|
</span>
|
|
239
240
|
}
|
|
240
241
|
>
|
|
241
|
-
Aggregates
|
|
242
|
+
{hasModelGroups ? "Aggregates and model groups" : "Aggregates"}
|
|
242
243
|
</SectionTitle>
|
|
243
244
|
{aggregates.length === 0 ? (
|
|
244
245
|
<Empty>this domain owns nothing yet — only services</Empty>
|
|
@@ -264,7 +265,7 @@ export function ContextPage() {
|
|
|
264
265
|
{aggregate.name}
|
|
265
266
|
</Link>
|
|
266
267
|
<span className="meta">{service.slug}</span>
|
|
267
|
-
<span className="meta">root {aggregate.root}</span>
|
|
268
|
+
<span className="meta">{aggregate.kind === "model-group" ? "model group · boundary not specified" : `root ${aggregate.root}`}</span>
|
|
268
269
|
<RowActions
|
|
269
270
|
copy={aggregate.id}
|
|
270
271
|
reveal={aggregate.id}
|
|
@@ -130,6 +130,7 @@ export function ServicePage() {
|
|
|
130
130
|
.find((source) => source !== undefined && hasAsyncSpec(source));
|
|
131
131
|
const showDomain =
|
|
132
132
|
componentKind(service) === "service" || service.aggregates.length > 0;
|
|
133
|
+
const hasModelGroups = service.aggregates.some((aggregate) => aggregate.kind === "model-group");
|
|
133
134
|
const integrations = integrationsFor(service, catalog);
|
|
134
135
|
|
|
135
136
|
const counts: Record<Tab, number | null> = {
|
|
@@ -219,7 +220,7 @@ export function ServicePage() {
|
|
|
219
220
|
className="rounded-control hover:text-ink"
|
|
220
221
|
>
|
|
221
222
|
<span className="tnum">{service.aggregates.length}</span>{" "}
|
|
222
|
-
{plural(service.aggregates.length, "aggregate")}
|
|
223
|
+
{plural(service.aggregates.length, hasModelGroups ? "group" : "aggregate")}
|
|
223
224
|
</a>
|
|
224
225
|
) : null}
|
|
225
226
|
{showDomain ? (
|
|
@@ -312,7 +313,7 @@ export function ServicePage() {
|
|
|
312
313
|
className="mt-section max-w-table"
|
|
313
314
|
>
|
|
314
315
|
<SectionTitle anchor={SERVICE_ANCHOR.aggregates}>
|
|
315
|
-
Aggregates
|
|
316
|
+
{hasModelGroups ? "Aggregates and model groups" : "Aggregates"}
|
|
316
317
|
</SectionTitle>
|
|
317
318
|
{/* icon, slug, name, event count, actions - one column each,
|
|
318
319
|
so the counts stack up instead of drifting with the name. */}
|
|
@@ -336,7 +337,7 @@ export function ServicePage() {
|
|
|
336
337
|
>
|
|
337
338
|
{aggregate.slug}
|
|
338
339
|
</Link>
|
|
339
|
-
<span className="meta">{aggregate.name}</span>
|
|
340
|
+
<span className="meta">{aggregate.name}{aggregate.kind === "model-group" ? " · model group" : ""}</span>
|
|
340
341
|
<Link
|
|
341
342
|
to={`${to}#bb-events`}
|
|
342
343
|
className="mono rounded-control hover:underline"
|