@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.
Files changed (151) hide show
  1. package/README.md +23 -0
  2. package/catalog/enum_test.go +46 -0
  3. package/catalog/evidence_test.go +35 -0
  4. package/catalog/model.go +1066 -0
  5. package/catalog/roundtrip_test.go +203 -0
  6. package/catalog/via_test.go +38 -0
  7. package/cli/init.test.mjs +6 -1
  8. package/cli/portolan.mjs +8 -0
  9. package/cli/portolan.test.mjs +49 -0
  10. package/go.mod +14 -0
  11. package/go.sum +20 -0
  12. package/internal/gocall/README.md +19 -0
  13. package/internal/gocall/analyze.go +189 -0
  14. package/internal/gocall/analyze_test.go +107 -0
  15. package/internal/gohttp/analyze.go +2562 -0
  16. package/internal/gohttp/destination.go +373 -0
  17. package/internal/gohttp/endpoints.go +1067 -0
  18. package/internal/gohttp/roots.go +320 -0
  19. package/internal/gohttp/typed.go +96 -0
  20. package/internal/goscan/constants.go +85 -0
  21. package/internal/goscan/goscan_test.go +227 -0
  22. package/internal/goscan/index.go +629 -0
  23. package/internal/goscan/index_test.go +66 -0
  24. package/internal/goscan/names.go +52 -0
  25. package/internal/goscan/parse_test.go +11 -0
  26. package/internal/goscan/source.go +37 -0
  27. package/internal/goscan/tree.go +284 -0
  28. package/internal/goscan/types.go +99 -0
  29. package/internal/wsdl/ids.go +127 -0
  30. package/internal/wsdl/ids_test.go +21 -0
  31. package/internal/wsdl/model.go +70 -0
  32. package/internal/wsdl/parse.go +949 -0
  33. package/internal/wsdl/parse_test.go +170 -0
  34. package/package.json +22 -10
  35. package/plugin/describe.go +118 -0
  36. package/plugin/describe_test.go +114 -0
  37. package/plugin/protocol.go +141 -0
  38. package/plugin/schematest/schematest.go +126 -0
  39. package/plugins/README.md +109 -46
  40. package/plugins/cmd/portolan-http-clients/main.go +19 -0
  41. package/plugins/extract-celery/extract.py +0 -2
  42. package/plugins/extract-celery/extract_test.py +1 -1
  43. package/plugins/extract-django/README.md +39 -17
  44. package/plugins/extract-django/domain.py +28 -17
  45. package/plugins/extract-django/extract.py +21 -7
  46. package/plugins/extract-django/extract_test.py +55 -2
  47. package/plugins/extract-django/lifecycle.py +2 -0
  48. package/plugins/extract-django/operations.py +1 -1
  49. package/plugins/extract-django/routing_test.py +109 -1
  50. package/plugins/extract-django/store.py +1 -1
  51. package/plugins/extract-django/transport.py +101 -55
  52. package/plugins/extract-django/verbs.py +241 -0
  53. package/plugins/extract-go/README.md +47 -0
  54. package/plugins/extract-http-clients/describe.go +19 -0
  55. package/plugins/extract-http-clients/describe_test.go +11 -0
  56. package/plugins/extract-http-clients/extract.go +740 -0
  57. package/plugins/extract-http-clients/extract_test.go +1561 -0
  58. package/plugins/extract-http-clients/main.go +41 -0
  59. package/plugins/extract-java/build/org/portolan/extract/Extract.class +0 -0
  60. package/plugins/extract-java/build/org/portolan/extract/Protocol$Builder.class +0 -0
  61. package/plugins/extract-java/build/org/portolan/extract/Protocol$Input.class +0 -0
  62. package/plugins/extract-java/build/org/portolan/extract/Protocol$Options.class +0 -0
  63. package/plugins/extract-python-kafka/extract.py +0 -2
  64. package/plugins/extract-python-kafka/extract_test.py +1 -1
  65. package/plugins/extract-ts/extract.test.ts +2 -2
  66. package/plugins/extract-ts/extract.ts +4 -5
  67. package/plugins/extract-ts/graphql.test.ts +1 -1
  68. package/plugins/openapi/ids.go +261 -0
  69. package/plugins/openapi/ids_test.go +98 -0
  70. package/plugins/portolan-go.wasm +0 -0
  71. package/plugins/pyplugin/protocol.py +1 -5
  72. package/portolan.json +3 -2
  73. package/schema/portolan.schema.json +34 -0
  74. package/scripts/README.md +18 -12
  75. package/scripts/catalog-sources.mjs +6 -0
  76. package/scripts/delivery-presets.mjs +21 -11
  77. package/scripts/diff.mjs +5 -1
  78. package/scripts/django-aggregates.test.mjs +58 -0
  79. package/scripts/gen-likec4.mjs +1 -1
  80. package/scripts/gen.mjs +118 -115
  81. package/scripts/go-discovery.test.mjs +30 -0
  82. package/scripts/history.mjs +186 -3
  83. package/scripts/history.test.mjs +1 -1
  84. package/scripts/host-plugins/fetch-git.mjs +77 -21
  85. package/scripts/host-plugins/fetch-git.test.mjs +62 -8
  86. package/scripts/local-api.mjs +71 -4
  87. package/scripts/local-api.test.mjs +63 -4
  88. package/scripts/local-discovery.mjs +82 -9
  89. package/scripts/manifest.mjs +5 -3
  90. package/scripts/manifest.test.mjs +24 -0
  91. package/scripts/output-diff.mjs +94 -0
  92. package/scripts/output-diff.test.mjs +36 -0
  93. package/scripts/package-smoke.mjs +62 -4
  94. package/scripts/plugin-host.mjs +22 -2
  95. package/scripts/plugin-host.test.mjs +9 -0
  96. package/scripts/plugin-wasm-worker.mjs +4 -1
  97. package/scripts/provenance.mjs +72 -0
  98. package/scripts/provenance.test.mjs +149 -0
  99. package/scripts/run-builtin.mjs +39 -5
  100. package/scripts/schema.mjs +29 -0
  101. package/scripts/warning-policy.mjs +161 -0
  102. package/scripts/warning-policy.test.mjs +56 -0
  103. package/src/app/Sidebar.tsx +3 -3
  104. package/src/catalog-docs.test.ts +64 -0
  105. package/src/catalog-docs.ts +35 -0
  106. package/src/catalog-error.test.ts +15 -0
  107. package/src/catalog-model.ts +48 -5
  108. package/src/catalog-validation.ts +9 -0
  109. package/src/chat/Starter.tsx +5 -11
  110. package/src/chat/tools.test.ts +27 -0
  111. package/src/chat/tools.ts +5 -9
  112. package/src/components/CatalogStamp.tsx +10 -8
  113. package/src/components/HTTPDestinationEvidence.test.tsx +23 -0
  114. package/src/components/HTTPDestinationEvidence.tsx +31 -0
  115. package/src/components/Integrations.tsx +1 -1
  116. package/src/components/MachineDocs.tsx +6 -5
  117. package/src/components/MethodRows.tsx +9 -2
  118. package/src/components/RelationEvidence.test.tsx +14 -0
  119. package/src/components/RelationEvidence.tsx +53 -0
  120. package/src/data.ts +25 -7
  121. package/src/enrich.test.ts +332 -1
  122. package/src/enrich.ts +206 -3
  123. package/src/flow/StepDetail.tsx +6 -0
  124. package/src/flow/evidence.test.ts +16 -0
  125. package/src/flow/evidence.ts +34 -0
  126. package/src/index.css +44 -0
  127. package/src/landing/DraggableReveal.tsx +3 -2
  128. package/src/landing/EvidencePipeline.tsx +105 -0
  129. package/src/landing/LandingPage.tsx +2 -59
  130. package/src/lib/catalog-diff.ts +1 -1
  131. package/src/lib/django-aggregates.d.mts +9 -0
  132. package/src/lib/django-aggregates.mjs +36 -0
  133. package/src/lib/django-aggregates.test.ts +29 -0
  134. package/src/lib/django-aggregates.ts +5 -0
  135. package/src/lib/local-api.ts +20 -2
  136. package/src/lib/setup-info.test.ts +17 -0
  137. package/src/lib/setup-info.ts +58 -0
  138. package/src/lib/warnings.test.ts +54 -0
  139. package/src/lib/warnings.ts +260 -0
  140. package/src/map/ContextMapGraph.tsx +76 -32
  141. package/src/merge.ts +16 -9
  142. package/src/pages/AggregatePage.tsx +8 -7
  143. package/src/pages/ContextPage.tsx +6 -5
  144. package/src/pages/ServicePage.tsx +4 -3
  145. package/src/pages/Settings.tsx +189 -41
  146. package/src/pages/settings/DjangoAggregateChoices.tsx +79 -0
  147. package/src/selection/DetailPanel.tsx +15 -0
  148. package/src/virtual-provenance.d.ts +11 -0
  149. package/vite.config.ts +5 -0
  150. package/scripts/vendor-lock.mjs +0 -58
  151. 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 { useCallback, useEffect, useMemo, useState } from "react";
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 className="canvas-motion relative h-full w-full">
261
- {drawn.ready ? null : <DiagramSkeleton />}
262
- <ReactFlow
263
- nodes={shownNodes}
264
- edges={shownEdges}
265
- nodeTypes={contextNodeTypes}
266
- edgeTypes={mapEdgeTypes}
267
- onNodeClick={onNodeClick}
268
- onNodeDoubleClick={onNodeDoubleClick}
269
- onPaneClick={() => clear("diagram")}
270
- nodesDraggable={false}
271
- nodesConnectable={false}
272
- elementsSelectable
273
- proOptions={{ hideAttribution: true }}
274
- fitView
275
- fitViewOptions={{ padding: compactLayout ? 0.06 : 0.16 }}
276
- zoomOnScroll={zoomOnScroll}
277
- preventScrolling={zoomOnScroll}
278
- minZoom={0.2}
279
- maxZoom={1.6}
280
- key={fitKey}
281
- >
282
- <Background gap={20} size={2} />
283
- {/* The map has no modes to switch, so its only control is the way
284
- out: the drawing as a file, for the page that explains it. */}
285
- {showExport ? (
286
- <Panel position="top-right">
287
- <ExportSeg name="context-map" />
288
- </Panel>
289
- ) : null}
290
- </ReactFlow>
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
- * The host stamps what it generates, from the last commit that touched the
46
- * input the fragment was read from. A file nobody generates - the estate's
47
- * hand-written facts - is stamped by nobody, and a provenance typed in by hand
48
- * is a claim no process keeps true: it stays where it was typed while the
49
- * estate moves on, and being the oldest stamp in the corpus it then dates the
50
- * whole merged catalog. So a source is allowed to carry no stamp at all, and
51
- * saying nothing is treated as what it is - an absence of evidence, which
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"