@shortlink-org/portolan 0.3.0 → 0.4.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 +151 -6
- package/catalog/model.go +147 -0
- package/catalog/roundtrip_test.go +7 -0
- package/cli/portolan.mjs +6 -1
- package/go.mod +7 -0
- package/go.sum +20 -6
- package/package.json +10 -9
- package/plugin/describe.go +38 -0
- package/plugins/README.md +296 -5
- package/plugins/extract-argocd/options.schema.json +44 -0
- package/plugins/extract-celery/main.py +1 -0
- package/plugins/extract-csharp-ddd/README.md +213 -0
- package/plugins/extract-csharp-ddd/options.schema.json +55 -0
- package/plugins/extract-django/README.md +13 -2
- package/plugins/extract-django/choices.py +47 -0
- package/plugins/extract-django/domain.py +2 -1
- package/plugins/extract-django/extract_test.py +13 -0
- package/plugins/extract-django/lifecycle.py +2 -28
- package/plugins/extract-django/main.py +1 -0
- package/plugins/extract-django/rules.py +154 -0
- package/plugins/extract-django/rules_test.py +158 -0
- package/plugins/extract-go-sqs/options.schema.json +21 -0
- package/plugins/extract-http-clients/describe.go +5 -4
- package/plugins/extract-java/build/org/portolan/extract/Main.class +0 -0
- package/plugins/extract-k8s/options.schema.json +32 -0
- package/plugins/extract-laravel/Cargo.lock +962 -0
- package/plugins/extract-laravel/Cargo.toml +20 -0
- package/plugins/extract-laravel/README.md +200 -0
- package/plugins/extract-laravel/options.schema.json +68 -0
- package/plugins/extract-laravel/rustfmt.toml +1 -0
- package/plugins/extract-php-ddd/Cargo.lock +972 -0
- package/plugins/extract-php-ddd/Cargo.toml +22 -0
- package/plugins/extract-php-ddd/README.md +141 -0
- package/plugins/extract-php-ddd/options.schema.json +50 -0
- package/plugins/extract-php-ddd/rustfmt.toml +1 -0
- package/plugins/extract-python-kafka/main.py +1 -0
- package/plugins/extract-terraform/options.schema.json +26 -0
- package/plugins/extract-ts/main.ts +1 -0
- package/plugins/phpscan/Cargo.lock +953 -0
- package/plugins/phpscan/Cargo.toml +19 -0
- package/plugins/phpscan/rustfmt.toml +1 -0
- package/plugins/portolan-go.wasm +0 -0
- package/plugins/pyplugin/catalog.py +24 -1
- package/plugins/verify-otel/options.schema.json +12 -0
- package/portolan.json +130 -3
- package/rules/builtin.json +218 -0
- package/schema/portolan.schema.json +880 -13
- package/scripts/README.md +3 -1
- package/scripts/delivery-presets.mjs +36 -3
- package/scripts/gen-likec4.mjs +149 -16
- package/scripts/gen-likec4.test.mjs +96 -0
- package/scripts/gen.mjs +30 -3
- package/scripts/gitops-example.test.mjs +108 -0
- package/scripts/host-plugins/fetch-argocd.mjs +338 -0
- package/scripts/host-plugins/fetch-argocd.options.json +49 -0
- package/scripts/host-plugins/fetch-argocd.test.mjs +274 -0
- package/scripts/host-plugins/fetch-bsr.mjs +1 -0
- package/scripts/host-plugins/fetch-csr.mjs +1 -0
- package/scripts/host-plugins/fetch-git.mjs +1 -0
- package/scripts/host-plugins/fetch-k8s.mjs +263 -0
- package/scripts/host-plugins/fetch-k8s.options.json +50 -0
- package/scripts/host-plugins/fetch-k8s.test.mjs +259 -0
- package/scripts/host-plugins/k8s-topology.mjs +183 -0
- package/scripts/json-format.mjs +192 -0
- package/scripts/json-format.test.mjs +97 -0
- package/scripts/local-api.mjs +216 -8
- package/scripts/local-api.test.mjs +88 -2
- package/scripts/local-discovery.mjs +12 -0
- package/scripts/manifest.mjs +10 -1
- package/scripts/plugin-host.mjs +23 -1
- package/scripts/plugins-fresh.mjs +97 -0
- package/scripts/plugins-fresh.test.mjs +64 -0
- package/scripts/run-builtin.mjs +26 -5
- package/scripts/schema.mjs +138 -19
- package/scripts/trace-trials.mjs +176 -0
- package/scripts/trace-trials.test.mjs +142 -0
- package/scripts/warning-policy.mjs +12 -6
- package/scripts/warning-policy.test.mjs +40 -3
- package/src/app/Breadcrumbs.test.ts +3 -0
- package/src/app/Breadcrumbs.tsx +3 -0
- package/src/app/CatalogApp.tsx +2 -0
- package/src/app/SidebarFooter.tsx +20 -4
- package/src/catalog-index.ts +25 -0
- package/src/catalog-model.ts +246 -1
- package/src/catalog-validation.ts +104 -2
- package/src/catalog.test.ts +40 -1
- package/src/components/ChannelRows.test.tsx +76 -1
- package/src/components/ChannelRows.tsx +223 -60
- package/src/components/DeploymentRows.tsx +131 -0
- package/src/components/DocsLinks.test.tsx +27 -0
- package/src/components/DocsLinks.tsx +56 -0
- package/src/components/FieldTree.tsx +5 -2
- package/src/components/HTTPDestinationEvidence.tsx +1 -1
- package/src/components/PluginIcon.tsx +77 -0
- package/src/components/ProblemRow.tsx +173 -153
- package/src/components/RuleMarks.tsx +22 -0
- package/src/components/ShapeRows.tsx +24 -20
- package/src/data.ts +21 -0
- package/src/enrich.test.ts +127 -3
- package/src/enrich.ts +104 -6
- package/src/er/ErCanvas.tsx +218 -12
- package/src/er/GroupNode.tsx +57 -0
- package/src/er/StoreHeader.tsx +1 -0
- package/src/er/layout.test.ts +85 -2
- package/src/er/layout.ts +140 -5
- package/src/er/spec.test.ts +17 -0
- package/src/er/spec.ts +23 -10
- package/src/flow/Recordings.test.tsx +52 -0
- package/src/flow/Recordings.tsx +236 -0
- package/src/flow/StepDetail.tsx +53 -0
- package/src/flow/TraceTrial.tsx +419 -0
- package/src/flow/examples.test.ts +33 -0
- package/src/flow/examples.ts +37 -0
- package/src/flow/outline.test.ts +28 -0
- package/src/flow/outline.ts +7 -2
- package/src/flow/trace-trial-resume.test.ts +42 -0
- package/src/flow/trace-trial-resume.ts +74 -0
- package/src/graph/elk.ts +78 -0
- package/src/index.css +24 -0
- package/src/landing/LandingPage.tsx +15 -11
- package/src/landing/ProductTour.tsx +6 -6
- package/src/lib/all-problems.ts +27 -17
- package/src/lib/centrality.test.ts +251 -0
- package/src/lib/centrality.ts +232 -0
- package/src/lib/confluence.test.ts +37 -0
- package/src/lib/confluence.ts +41 -0
- package/src/lib/context-color.ts +1 -1
- package/src/lib/deployment-drift.ts +22 -0
- package/src/lib/derive.ts +43 -69
- package/src/lib/environments.test.ts +83 -0
- package/src/lib/environments.ts +57 -0
- package/src/lib/integration-url.test.ts +30 -0
- package/src/lib/integration-url.ts +63 -0
- package/src/lib/kafka-ui.ts +3 -45
- package/src/lib/local-api.ts +96 -2
- package/src/lib/notion.ts +13 -0
- package/src/lib/plugin-index.json +2765 -0
- package/src/lib/plugins.test.ts +68 -0
- package/src/lib/plugins.ts +264 -0
- package/src/lib/problem-flows.test.ts +61 -0
- package/src/lib/problem-flows.ts +78 -0
- package/src/lib/problem-rules-cel.d.mts +43 -0
- package/src/lib/problem-rules-cel.mjs +407 -0
- package/src/lib/problem-rules.test.ts +287 -0
- package/src/lib/problem-rules.ts +271 -0
- package/src/lib/problem-subjects.ts +737 -0
- package/src/lib/rule-entries.ts +39 -0
- package/src/lib/{data-problems.test.ts → rules-data.test.ts} +18 -16
- package/src/lib/rules-deploy.test.ts +133 -0
- package/src/lib/{problems.test.ts → rules-edges.test.ts} +8 -3
- package/src/lib/{proto-problems.test.ts → rules-proto.test.ts} +9 -25
- package/src/lib/{wire-problems.test.ts → rules-wire.test.ts} +13 -11
- package/src/lib/rules.test.ts +51 -0
- package/src/lib/rules.ts +86 -0
- package/src/lib/shape.test.ts +32 -0
- package/src/lib/shape.ts +30 -6
- package/src/lib/tech.ts +16 -0
- package/src/lib/trace-project.test.ts +34 -0
- package/src/lib/trace-project.ts +50 -0
- package/src/lib/use-problems.ts +23 -0
- package/src/lib/warnings.test.ts +9 -0
- package/src/lib/warnings.ts +10 -10
- package/src/likec4/ids.test.ts +6 -2
- package/src/likec4/ids.ts +43 -0
- package/src/main.tsx +23 -0
- package/src/merge-deployments.test.ts +127 -0
- package/src/merge.test.ts +82 -0
- package/src/merge.ts +173 -9
- package/src/pages/AggregatePage.tsx +57 -7
- package/src/pages/ContextMap.tsx +45 -3
- package/src/pages/ContextPage.tsx +2 -0
- package/src/pages/EventPage.tsx +15 -5
- package/src/pages/FlowDetail.tsx +23 -2
- package/src/pages/GraphPage.tsx +40 -3
- package/src/pages/Overview.tsx +152 -12
- package/src/pages/PluginIndex.tsx +190 -0
- package/src/pages/Problems.tsx +396 -128
- package/src/pages/ServicePage.tsx +58 -2
- package/src/pages/Settings.tsx +28 -2
- package/src/pages/settings/AboutSettings.tsx +8 -1
- package/src/pages/settings/IntegrationsSettings.tsx +63 -17
- package/src/pages/settings/RecordingSettings.tsx +138 -0
- package/src/pages/settings/RulesSettings.tsx +825 -0
- package/src/routes.test.ts +9 -0
- package/src/routes.ts +22 -1
- package/src/lib/data-problems.ts +0 -314
- package/src/lib/proto-problems.ts +0 -237
- package/src/lib/wire-problems.ts +0 -342
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// A recording's trial, remembered across a reload.
|
|
2
|
+
//
|
|
3
|
+
// Keeping a recording writes portolan.json and then regenerates the catalog,
|
|
4
|
+
// and each of those reloads the page in development - the manifest and the
|
|
5
|
+
// sources are imported, not fetched. The runs go on in the server, which
|
|
6
|
+
// keeps every run's events and replays them to whoever asks; what is lost is
|
|
7
|
+
// the dialog watching them. So the page writes down which run it was
|
|
8
|
+
// watching, and the dialog picks the run back up when the page comes back.
|
|
9
|
+
|
|
10
|
+
export interface TraceTrialMemory {
|
|
11
|
+
/** The trial run the dialog was opened on. */
|
|
12
|
+
runId: string;
|
|
13
|
+
/** The write run started by keeping the recording, once there is one. */
|
|
14
|
+
writeRunId: string | null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The storage this reads and writes: sessionStorage in the browser, anything shaped like it in a test. */
|
|
18
|
+
export interface TrialStorage {
|
|
19
|
+
getItem(key: string): string | null;
|
|
20
|
+
setItem(key: string, value: string): void;
|
|
21
|
+
removeItem(key: string): void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const KEY = "portolan.trace-trial.v1";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* `scope` says which page was watching: a flow's slug, or "settings". A
|
|
28
|
+
* memory left by one page is not picked up by another - a dialog that pops
|
|
29
|
+
* up on a page the reader did not open it on is a surprise, not a resume.
|
|
30
|
+
*/
|
|
31
|
+
export function rememberTraceTrial(
|
|
32
|
+
storage: TrialStorage | null,
|
|
33
|
+
scope: string,
|
|
34
|
+
memory: TraceTrialMemory,
|
|
35
|
+
): void {
|
|
36
|
+
try {
|
|
37
|
+
storage?.setItem(KEY, JSON.stringify({ scope, ...memory }));
|
|
38
|
+
} catch {
|
|
39
|
+
// Storage full or forbidden: the dialog simply does not survive a reload.
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function recallTraceTrial(
|
|
44
|
+
storage: TrialStorage | null,
|
|
45
|
+
scope: string,
|
|
46
|
+
): TraceTrialMemory | null {
|
|
47
|
+
try {
|
|
48
|
+
const raw = storage?.getItem(KEY);
|
|
49
|
+
if (!raw) return null;
|
|
50
|
+
const value = JSON.parse(raw) as Partial<TraceTrialMemory> & { scope?: unknown };
|
|
51
|
+
if (value.scope !== scope || typeof value.runId !== "string" || !value.runId) return null;
|
|
52
|
+
|
|
53
|
+
return { runId: value.runId, writeRunId: typeof value.writeRunId === "string" ? value.writeRunId : null };
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function forgetTraceTrial(storage: TrialStorage | null): void {
|
|
60
|
+
try {
|
|
61
|
+
storage?.removeItem(KEY);
|
|
62
|
+
} catch {
|
|
63
|
+
// Nothing to forget, or nowhere to forget it from.
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The browser's session storage, or null where there is none. */
|
|
68
|
+
export function sessionStore(): TrialStorage | null {
|
|
69
|
+
try {
|
|
70
|
+
return typeof sessionStorage === "undefined" ? null : sessionStorage;
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
package/src/graph/elk.ts
CHANGED
|
@@ -190,6 +190,84 @@ export async function layoutWithElk(input: LayoutInput): Promise<LayoutResult> {
|
|
|
190
190
|
};
|
|
191
191
|
}
|
|
192
192
|
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
// Groups of nodes, packed
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
export interface GroupLayoutInput {
|
|
198
|
+
groups: {
|
|
199
|
+
id: string;
|
|
200
|
+
nodes: { id: string; width: number; height: number }[];
|
|
201
|
+
/** Edges between this group's own nodes; one across groups is not laid out. */
|
|
202
|
+
edges: { id: string; source: string; target: string }[];
|
|
203
|
+
}[];
|
|
204
|
+
/** Width over height the packed whole should come out near: the canvas's. */
|
|
205
|
+
aspectRatio: number;
|
|
206
|
+
layerSpacing?: number;
|
|
207
|
+
nodeSpacing?: number;
|
|
208
|
+
/** Room between two groups' frames. */
|
|
209
|
+
groupSpacing?: number;
|
|
210
|
+
/** Room a frame keeps above its nodes, for its label. */
|
|
211
|
+
labelHeight?: number;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export interface GroupLayoutResult {
|
|
215
|
+
/** Every node, in the coordinates of the whole picture. */
|
|
216
|
+
positions: Record<string, Point>;
|
|
217
|
+
/** Each group's frame, in the same coordinates. */
|
|
218
|
+
frames: Record<string, { x: number; y: number; width: number; height: number }>;
|
|
219
|
+
width: number;
|
|
220
|
+
height: number;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* A layered layout inside each group and the groups packed as rectangles
|
|
225
|
+
* outside, to an aspect ratio. This is for a graph that a single layered pass
|
|
226
|
+
* cannot draw: one with a layer of fifty nodes, which comes out as a column
|
|
227
|
+
* whatever direction it is read in. Packing ignores the edges between
|
|
228
|
+
* groups, deliberately - the caller draws those itself, and a packing that
|
|
229
|
+
* tried to shorten them would trade the readable shape for it.
|
|
230
|
+
*/
|
|
231
|
+
export async function layoutGroupsWithElk(input: GroupLayoutInput): Promise<GroupLayoutResult> {
|
|
232
|
+
const labelHeight = input.labelHeight ?? 28;
|
|
233
|
+
const graph: ElkNode = {
|
|
234
|
+
id: "root",
|
|
235
|
+
layoutOptions: {
|
|
236
|
+
"elk.algorithm": "rectpacking",
|
|
237
|
+
"elk.aspectRatio": String(input.aspectRatio),
|
|
238
|
+
"elk.spacing.nodeNode": String(input.groupSpacing ?? 40),
|
|
239
|
+
"elk.padding": "[top=16,left=16,bottom=16,right=16]",
|
|
240
|
+
},
|
|
241
|
+
children: input.groups.map((group) => ({
|
|
242
|
+
id: group.id,
|
|
243
|
+
layoutOptions: {
|
|
244
|
+
"elk.algorithm": "layered",
|
|
245
|
+
"elk.direction": "RIGHT",
|
|
246
|
+
"elk.layered.spacing.nodeNodeBetweenLayers": String(input.layerSpacing ?? 120),
|
|
247
|
+
"elk.spacing.nodeNode": String(input.nodeSpacing ?? 24),
|
|
248
|
+
"elk.layered.nodePlacement.strategy": "NETWORK_SIMPLEX",
|
|
249
|
+
"elk.padding": `[top=${labelHeight},left=12,bottom=12,right=12]`,
|
|
250
|
+
},
|
|
251
|
+
children: group.nodes.map((n) => ({ id: n.id, width: n.width, height: n.height })),
|
|
252
|
+
edges: group.edges.map((e) => ({ id: e.id, sources: [e.source], targets: [e.target] })),
|
|
253
|
+
})),
|
|
254
|
+
edges: [],
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const laid = await elk.layout(graph);
|
|
258
|
+
const positions: Record<string, Point> = {};
|
|
259
|
+
const frames: GroupLayoutResult["frames"] = {};
|
|
260
|
+
for (const group of laid.children ?? []) {
|
|
261
|
+
const gx = group.x ?? 0;
|
|
262
|
+
const gy = group.y ?? 0;
|
|
263
|
+
frames[group.id] = { x: gx, y: gy, width: group.width ?? 0, height: group.height ?? 0 };
|
|
264
|
+
for (const child of group.children ?? []) {
|
|
265
|
+
positions[child.id] = { x: gx + (child.x ?? 0), y: gy + (child.y ?? 0) };
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return { positions, frames, width: laid.width ?? 0, height: laid.height ?? 0 };
|
|
269
|
+
}
|
|
270
|
+
|
|
193
271
|
// ---------------------------------------------------------------------------
|
|
194
272
|
// Drawing what elk answered
|
|
195
273
|
// ---------------------------------------------------------------------------
|
package/src/index.css
CHANGED
|
@@ -51,6 +51,12 @@
|
|
|
51
51
|
--status-declared: #8a6d1f;
|
|
52
52
|
--status-unresolved: #c02a29;
|
|
53
53
|
|
|
54
|
+
/* Which way a message travels on a channel: out is the accent's blue, in
|
|
55
|
+
is teal, far enough apart that a row reads at a glance and neither is a
|
|
56
|
+
status colour. */
|
|
57
|
+
--dir-send: #2563a8;
|
|
58
|
+
--dir-receive: #0f7a6c;
|
|
59
|
+
|
|
54
60
|
/* Domain events are the loudest leaf in the tree. Amber, but a step darker
|
|
55
61
|
than amber-600 so 12px mono still clears contrast on white. */
|
|
56
62
|
--kind-event: #b45309;
|
|
@@ -173,6 +179,9 @@
|
|
|
173
179
|
--status-declared: #d6a844;
|
|
174
180
|
--status-unresolved: #ff6b63;
|
|
175
181
|
|
|
182
|
+
--dir-send: #82b4f4;
|
|
183
|
+
--dir-receive: #5fcbb3;
|
|
184
|
+
|
|
176
185
|
--kind-event: #e8a33d;
|
|
177
186
|
--response-error: #d98288;
|
|
178
187
|
--response-error-bg: #2a1c20;
|
|
@@ -428,6 +437,16 @@
|
|
|
428
437
|
border-color: var(--status-unresolved);
|
|
429
438
|
}
|
|
430
439
|
|
|
440
|
+
/* A message's direction on a channel, worn by the chip that names it. */
|
|
441
|
+
.dir-send {
|
|
442
|
+
color: var(--dir-send);
|
|
443
|
+
border-color: var(--dir-send);
|
|
444
|
+
}
|
|
445
|
+
.dir-receive {
|
|
446
|
+
color: var(--dir-receive);
|
|
447
|
+
border-color: var(--dir-receive);
|
|
448
|
+
}
|
|
449
|
+
|
|
431
450
|
/* Section label above a block of content. */
|
|
432
451
|
.label {
|
|
433
452
|
@apply mono uppercase text-muted;
|
|
@@ -1485,6 +1504,11 @@
|
|
|
1485
1504
|
color: var(--fg-faint);
|
|
1486
1505
|
background: var(--surface-2);
|
|
1487
1506
|
}
|
|
1507
|
+
/* A rule on the value - "≥ 1 chars", "matches ^[A-Z]{3}$" - in the same
|
|
1508
|
+
tone. A pattern can run long; the tooltip carries the whole of it. */
|
|
1509
|
+
.rule-mark {
|
|
1510
|
+
@apply max-w-[18ch] truncate;
|
|
1511
|
+
}
|
|
1488
1512
|
}
|
|
1489
1513
|
|
|
1490
1514
|
/* ---------------------------------------------------------------------------
|
|
@@ -20,6 +20,7 @@ import { useTheme } from "../app/theme";
|
|
|
20
20
|
import { useDocumentTitle } from "../app/title";
|
|
21
21
|
import { catalog } from "../data";
|
|
22
22
|
import { m } from "../lib/motion";
|
|
23
|
+
import { landingInputs } from "../lib/plugins";
|
|
23
24
|
import { paths } from "../routes";
|
|
24
25
|
import { catalogTo } from "./catalog";
|
|
25
26
|
import { DraggableReveal } from "./DraggableReveal";
|
|
@@ -76,6 +77,9 @@ function Header() {
|
|
|
76
77
|
<a className="hover:text-ink" href="#get-started">
|
|
77
78
|
Get started
|
|
78
79
|
</a>
|
|
80
|
+
<Link className="hover:text-ink" to={pluginsTo}>
|
|
81
|
+
Plugins
|
|
82
|
+
</Link>
|
|
79
83
|
</nav>
|
|
80
84
|
<div className="ml-auto flex items-center gap-2">
|
|
81
85
|
<button
|
|
@@ -146,11 +150,10 @@ function HeroDemo() {
|
|
|
146
150
|
);
|
|
147
151
|
}
|
|
148
152
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
];
|
|
153
|
+
// What the plugins say they read, from the index `npm run schema` writes: a
|
|
154
|
+
// new extractor lands here without anyone remembering to add it.
|
|
155
|
+
const inputGroups = landingInputs();
|
|
156
|
+
const pluginsTo = catalogTo(paths.plugins());
|
|
154
157
|
|
|
155
158
|
const features = [
|
|
156
159
|
{
|
|
@@ -335,14 +338,12 @@ export function LandingPage() {
|
|
|
335
338
|
<div className="mx-auto flex max-w-[1200px] flex-col gap-5 px-5 py-7 sm:px-8 lg:flex-row lg:items-center">
|
|
336
339
|
<div className="shrink-0">
|
|
337
340
|
<div className="mono text-faint">BUILT-IN INPUTS</div>
|
|
338
|
-
<
|
|
339
|
-
|
|
340
|
-
target="_blank"
|
|
341
|
-
rel="noreferrer"
|
|
341
|
+
<Link
|
|
342
|
+
to={pluginsTo}
|
|
342
343
|
className="mt-1 inline-flex items-center gap-1 text-sm text-accent hover:underline"
|
|
343
344
|
>
|
|
344
|
-
See every
|
|
345
|
-
</
|
|
345
|
+
See every plugin <ArrowRight size={12} />
|
|
346
|
+
</Link>
|
|
346
347
|
</div>
|
|
347
348
|
<div className="flex flex-wrap gap-x-7 gap-y-3 lg:ml-auto lg:justify-end">
|
|
348
349
|
{inputGroups.map((group) => (
|
|
@@ -687,6 +688,9 @@ export function LandingPage() {
|
|
|
687
688
|
<a className="hover:text-ink" href={PRODUCT_README}>
|
|
688
689
|
Docs
|
|
689
690
|
</a>
|
|
691
|
+
<Link className="hover:text-ink" to={pluginsTo}>
|
|
692
|
+
Plugins
|
|
693
|
+
</Link>
|
|
690
694
|
<a
|
|
691
695
|
className="hover:text-ink"
|
|
692
696
|
href={PRODUCT_LICENSE}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// product's own canvases in a smaller box; the other two are read straight
|
|
3
3
|
// out of the same catalog. Nothing on this page is drawn from a mock-up.
|
|
4
4
|
|
|
5
|
-
import { lazy, Suspense,
|
|
5
|
+
import { lazy, Suspense, useState } from "react";
|
|
6
6
|
import { Link } from "react-router";
|
|
7
7
|
import {
|
|
8
8
|
AlertTriangle,
|
|
@@ -14,10 +14,10 @@ import {
|
|
|
14
14
|
Network,
|
|
15
15
|
Radio,
|
|
16
16
|
} from "lucide-react";
|
|
17
|
-
import {
|
|
17
|
+
import { index } from "../data";
|
|
18
18
|
import { DiagramSkeleton } from "../components/DiagramSkeleton";
|
|
19
19
|
import { contextVar } from "../lib/context-color";
|
|
20
|
-
import {
|
|
20
|
+
import { useProblems } from "../lib/use-problems";
|
|
21
21
|
import {
|
|
22
22
|
AnimatePresence,
|
|
23
23
|
LayoutGroup,
|
|
@@ -194,7 +194,7 @@ function ContractDemo() {
|
|
|
194
194
|
const SHOWN_PROBLEMS = 4;
|
|
195
195
|
|
|
196
196
|
function ProblemsDemo() {
|
|
197
|
-
const all =
|
|
197
|
+
const all = useProblems();
|
|
198
198
|
const errors = all.filter((p) => p.severity === "error").length;
|
|
199
199
|
const shown = all.slice(0, SHOWN_PROBLEMS);
|
|
200
200
|
|
|
@@ -228,7 +228,7 @@ function ProblemsDemo() {
|
|
|
228
228
|
<div className="space-y-2">
|
|
229
229
|
{shown.map((problem, i) => (
|
|
230
230
|
<div
|
|
231
|
-
key={`${problem.
|
|
231
|
+
key={`${problem.rule}:${problem.id}:${problem.peer}`}
|
|
232
232
|
style={staggerStyle(i)}
|
|
233
233
|
className="stagger-in grid grid-cols-[20px_minmax(0,1fr)_auto] items-start gap-3 rounded-card border border-line bg-canvas p-3"
|
|
234
234
|
>
|
|
@@ -255,7 +255,7 @@ function ProblemsDemo() {
|
|
|
255
255
|
</div>
|
|
256
256
|
</div>
|
|
257
257
|
<span className="chip border-line text-muted">
|
|
258
|
-
{problem.
|
|
258
|
+
{problem.rule}
|
|
259
259
|
</span>
|
|
260
260
|
</div>
|
|
261
261
|
))}
|
package/src/lib/all-problems.ts
CHANGED
|
@@ -1,26 +1,36 @@
|
|
|
1
1
|
// Every reading the catalog makes of itself, in one list.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
3
|
+
// The rules in force - the shipped ones, switched and re-graded as the
|
|
4
|
+
// manifest says, and the estate's own - run over the merged catalog, and
|
|
5
|
+
// what they find comes out as problems. The problems page, the overview and
|
|
6
|
+
// the landing all want the union, errors first - a boundary leak is not the
|
|
7
|
+
// same kind of news as a column whose type has drifted, and mixing them
|
|
8
|
+
// buries the first.
|
|
9
9
|
|
|
10
10
|
import type { Catalog, CatalogIndex } from "../catalog";
|
|
11
|
-
import { dataProblems } from "./data-problems";
|
|
12
|
-
import { problems } from "./derive";
|
|
13
11
|
import type { Problem } from "./derive";
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
12
|
+
import { currentRuleEntries, evaluateRules, resolveRules } from "./problem-rules";
|
|
13
|
+
import type { ProblemRule, RuleEvaluation } from "./problem-rules";
|
|
16
14
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
15
|
+
/** The rules over the catalog, with what they had to say about it. */
|
|
16
|
+
export function evaluateProblems(
|
|
17
|
+
catalog: Catalog,
|
|
18
|
+
index: CatalogIndex,
|
|
19
|
+
rules: readonly ProblemRule[] = resolveRules(currentRuleEntries()),
|
|
20
|
+
): RuleEvaluation {
|
|
21
|
+
const evaluation = evaluateRules(catalog, index, rules);
|
|
22
|
+
return { ...evaluation, problems: bySeverity(evaluation.problems) };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function allProblems(
|
|
26
|
+
catalog: Catalog,
|
|
27
|
+
index: CatalogIndex,
|
|
28
|
+
rules: readonly ProblemRule[] = resolveRules(currentRuleEntries()),
|
|
29
|
+
): Problem[] {
|
|
30
|
+
return evaluateProblems(catalog, index, rules).problems;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function bySeverity(found: Problem[]): Problem[] {
|
|
24
34
|
return [
|
|
25
35
|
...found.filter((p) => p.severity === "error"),
|
|
26
36
|
...found.filter((p) => p.severity === "warning"),
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { Catalog, Service } from "../catalog";
|
|
3
|
+
import { catalog } from "../testing/estate";
|
|
4
|
+
import { BROKER_SHARE, bridges, centrality, serviceGraph } from "./centrality";
|
|
5
|
+
|
|
6
|
+
// A catalog reduced to what the graph reads: contexts, services and the
|
|
7
|
+
// calls between them. `a -> b` is a call from a to b; the graph does not
|
|
8
|
+
// care which way, only that the two are joined.
|
|
9
|
+
function estate(
|
|
10
|
+
contexts: Record<string, string[]>,
|
|
11
|
+
edges: string[],
|
|
12
|
+
consumers: Record<string, string[]> = {},
|
|
13
|
+
): Catalog {
|
|
14
|
+
const service = (context: string, slug: string): Service => {
|
|
15
|
+
const id = `${context}.${slug}`;
|
|
16
|
+
return {
|
|
17
|
+
id,
|
|
18
|
+
slug,
|
|
19
|
+
name: slug,
|
|
20
|
+
repo: "",
|
|
21
|
+
path: "",
|
|
22
|
+
readme: "",
|
|
23
|
+
provides: [],
|
|
24
|
+
consumes: edges
|
|
25
|
+
.filter((e) => e.startsWith(`${id} -> `))
|
|
26
|
+
.map((e) => ({
|
|
27
|
+
id: `rpc/${e}`,
|
|
28
|
+
peer: e.slice(id.length + 4),
|
|
29
|
+
status: "verified" as const,
|
|
30
|
+
source: "test",
|
|
31
|
+
})),
|
|
32
|
+
aggregates:
|
|
33
|
+
consumers[id] === undefined
|
|
34
|
+
? []
|
|
35
|
+
: [
|
|
36
|
+
{
|
|
37
|
+
id: `${id}.root`,
|
|
38
|
+
slug: "root",
|
|
39
|
+
name: "root",
|
|
40
|
+
readme: "",
|
|
41
|
+
root: "root",
|
|
42
|
+
entities: [],
|
|
43
|
+
valueObjects: [],
|
|
44
|
+
operations: [],
|
|
45
|
+
events: [
|
|
46
|
+
{
|
|
47
|
+
id: `${id}.root.Happened`,
|
|
48
|
+
slug: "happened",
|
|
49
|
+
name: "Happened",
|
|
50
|
+
versions: [],
|
|
51
|
+
consumers: (consumers[id] ?? []).map((s) => ({
|
|
52
|
+
service: s,
|
|
53
|
+
status: "verified" as const,
|
|
54
|
+
})),
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
return {
|
|
62
|
+
generatedAt: "2026-01-01T00:00:00Z",
|
|
63
|
+
commit: "0000000",
|
|
64
|
+
contexts: Object.entries(contexts).map(([id, slugs]) => ({
|
|
65
|
+
id,
|
|
66
|
+
slug: id,
|
|
67
|
+
name: id,
|
|
68
|
+
summary: "",
|
|
69
|
+
services: slugs.map((slug) => service(id, slug)),
|
|
70
|
+
})),
|
|
71
|
+
defs: {},
|
|
72
|
+
flows: [],
|
|
73
|
+
adrs: [],
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const by = <T extends { id: string }>(list: T[], id: string): T =>
|
|
78
|
+
list.find((c) => c.id === id) ?? expect.fail(`no ${id}`);
|
|
79
|
+
|
|
80
|
+
describe("serviceGraph", () => {
|
|
81
|
+
it("joins a pair once whatever joins them, and both ways", () => {
|
|
82
|
+
const graph = serviceGraph(
|
|
83
|
+
estate({ a: ["x", "y"] }, ["a.x -> a.y", "a.y -> a.x"], {
|
|
84
|
+
"a.x": ["a.y"],
|
|
85
|
+
}),
|
|
86
|
+
);
|
|
87
|
+
expect(graph.adjacent.get("a.x")).toEqual(["a.y"]);
|
|
88
|
+
expect(graph.adjacent.get("a.y")).toEqual(["a.x"]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("leaves out ghosts, unresolved peers and a service consuming itself", () => {
|
|
92
|
+
const graph = serviceGraph(
|
|
93
|
+
estate({ a: ["x"] }, ["a.x -> fraud-scoring", "a.x -> a.x"], {
|
|
94
|
+
"a.x": ["analytics-sink", "a.x"],
|
|
95
|
+
}),
|
|
96
|
+
);
|
|
97
|
+
expect(graph.adjacent.get("a.x")).toEqual([]);
|
|
98
|
+
expect(graph.nodes).toEqual(["a.x"]);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe("centrality", () => {
|
|
103
|
+
it("is zero everywhere on an estate too small to have a middle", () => {
|
|
104
|
+
const two = centrality(estate({ a: ["x", "y"] }, ["a.x -> a.y"]));
|
|
105
|
+
expect(two.map((c) => c.betweenness)).toEqual([0, 0]);
|
|
106
|
+
expect(two.map((c) => c.degree)).toEqual([1, 1]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("puts the whole of a path through its middle and none through its ends", () => {
|
|
110
|
+
// x - y - z: the only pair not touching y is none, so y carries 1 of 1.
|
|
111
|
+
const path = centrality(
|
|
112
|
+
estate({ a: ["x", "y", "z"] }, ["a.x -> a.y", "a.y -> a.z"]),
|
|
113
|
+
);
|
|
114
|
+
expect(by(path, "a.y").betweenness).toBe(1);
|
|
115
|
+
expect(by(path, "a.x").betweenness).toBe(0);
|
|
116
|
+
expect(by(path, "a.z").betweenness).toBe(0);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("gives a star's hub everything and its leaves nothing", () => {
|
|
120
|
+
const star = centrality(
|
|
121
|
+
estate({ a: ["hub", "p", "q", "r"] }, [
|
|
122
|
+
"a.p -> a.hub",
|
|
123
|
+
"a.q -> a.hub",
|
|
124
|
+
"a.r -> a.hub",
|
|
125
|
+
]),
|
|
126
|
+
);
|
|
127
|
+
expect(by(star, "a.hub").betweenness).toBe(1);
|
|
128
|
+
expect(by(star, "a.hub").degree).toBe(3);
|
|
129
|
+
expect(by(star, "a.p").betweenness).toBe(0);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("splits a pair's paths between two equal roads", () => {
|
|
133
|
+
// x - y - z and x - w - z: y and w each carry half of the one pair.
|
|
134
|
+
const square = centrality(
|
|
135
|
+
estate({ a: ["x", "y", "w", "z"] }, [
|
|
136
|
+
"a.x -> a.y",
|
|
137
|
+
"a.x -> a.w",
|
|
138
|
+
"a.y -> a.z",
|
|
139
|
+
"a.w -> a.z",
|
|
140
|
+
]),
|
|
141
|
+
);
|
|
142
|
+
// Three pairs not touching y: (x,z) half through y, (x,w) and (w,z) not.
|
|
143
|
+
expect(by(square, "a.y").betweenness).toBeCloseTo(0.5 / 3);
|
|
144
|
+
expect(by(square, "a.w").betweenness).toBeCloseTo(0.5 / 3);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("names the context pairs a service is the road between", () => {
|
|
148
|
+
// auth.a - shop.gw - shop.core - pay.p: the gateway is the only road
|
|
149
|
+
// from auth to shop and to pay; core is the only road to pay from both.
|
|
150
|
+
const c = centrality(
|
|
151
|
+
estate({ auth: ["a"], shop: ["gw", "core"], pay: ["p"] }, [
|
|
152
|
+
"auth.a -> shop.gw",
|
|
153
|
+
"shop.gw -> shop.core",
|
|
154
|
+
"shop.core -> pay.p",
|
|
155
|
+
]),
|
|
156
|
+
);
|
|
157
|
+
expect(by(c, "shop.gw").between).toEqual([
|
|
158
|
+
{ a: "auth", b: "shop", share: 1 },
|
|
159
|
+
{ a: "auth", b: "pay", share: 1 },
|
|
160
|
+
]);
|
|
161
|
+
expect(by(c, "shop.core").between).toEqual([
|
|
162
|
+
{ a: "auth", b: "pay", share: 1 },
|
|
163
|
+
{ a: "shop", b: "pay", share: 1 },
|
|
164
|
+
]);
|
|
165
|
+
expect(by(c, "auth.a").between).toEqual([]);
|
|
166
|
+
expect(by(c, "pay.p").between).toEqual([]);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("does not call a service the road between two contexts when there is another as good", () => {
|
|
170
|
+
// auth.a reaches pay.p through shop.x or shop.y: each carries exactly
|
|
171
|
+
// half, and half is not more than half, so neither stands between.
|
|
172
|
+
const even = centrality(
|
|
173
|
+
estate({ auth: ["a"], shop: ["x", "y"], pay: ["p"] }, [
|
|
174
|
+
"auth.a -> shop.x",
|
|
175
|
+
"auth.a -> shop.y",
|
|
176
|
+
"shop.x -> pay.p",
|
|
177
|
+
"shop.y -> pay.p",
|
|
178
|
+
]),
|
|
179
|
+
);
|
|
180
|
+
expect(by(even, "shop.x").between).toEqual([]);
|
|
181
|
+
expect(by(even, "shop.y").between).toEqual([]);
|
|
182
|
+
expect(by(even, "shop.x").betweenness).toBeGreaterThan(0);
|
|
183
|
+
// A second auth service that reaches pay only through x tilts it: x now
|
|
184
|
+
// carries (1 + 0.5) of the 2 pairs, y carries 0.5 of them.
|
|
185
|
+
const tilted = centrality(
|
|
186
|
+
estate({ auth: ["a1", "a2"], shop: ["x", "y"], pay: ["p"] }, [
|
|
187
|
+
"auth.a1 -> shop.x",
|
|
188
|
+
"auth.a1 -> shop.y",
|
|
189
|
+
"auth.a2 -> shop.x",
|
|
190
|
+
"shop.x -> pay.p",
|
|
191
|
+
"shop.y -> pay.p",
|
|
192
|
+
]),
|
|
193
|
+
);
|
|
194
|
+
expect(by(tilted, "shop.x").between).toEqual([
|
|
195
|
+
{ a: "auth", b: "pay", share: 0.75 },
|
|
196
|
+
]);
|
|
197
|
+
expect(by(tilted, "shop.y").between).toEqual([]);
|
|
198
|
+
expect(BROKER_SHARE).toBe(0.5);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("does not count the roads a service is at the end of as roads it is between", () => {
|
|
202
|
+
// auth.a - shop.gw - shop.core: (a, gw) is a road gw is ON. Between
|
|
203
|
+
// auth and shop, gw carries the one remaining pair (a, core) in full.
|
|
204
|
+
const c = centrality(
|
|
205
|
+
estate({ auth: ["a"], shop: ["gw", "core"] }, [
|
|
206
|
+
"auth.a -> shop.gw",
|
|
207
|
+
"shop.gw -> shop.core",
|
|
208
|
+
]),
|
|
209
|
+
);
|
|
210
|
+
expect(by(c, "shop.gw").between).toEqual([{ a: "auth", b: "shop", share: 1 }]);
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
describe("bridges", () => {
|
|
215
|
+
it("lists the estate's roads between contexts, widest first", () => {
|
|
216
|
+
// In the frozen estate auth reaches everything through cart, and cart
|
|
217
|
+
// reaches everything but auth through pricing; oms is the only way from
|
|
218
|
+
// shop and auth into payments and delivery.
|
|
219
|
+
const list = bridges(catalog);
|
|
220
|
+
expect(list.map((b) => b.id)).toEqual([
|
|
221
|
+
"shop.oms",
|
|
222
|
+
"shop.pricing",
|
|
223
|
+
"shop.cart",
|
|
224
|
+
]);
|
|
225
|
+
expect(by(list, "shop.cart").between).toEqual([
|
|
226
|
+
{ a: "shop", b: "auth", share: 1 },
|
|
227
|
+
{ a: "payments", b: "auth", share: 1 },
|
|
228
|
+
{ a: "delivery", b: "auth", share: 1 },
|
|
229
|
+
]);
|
|
230
|
+
expect(by(list, "shop.oms").between.map((p) => `${p.a}~${p.b}`)).toEqual([
|
|
231
|
+
"shop~payments",
|
|
232
|
+
"shop~delivery",
|
|
233
|
+
"payments~auth",
|
|
234
|
+
"delivery~auth",
|
|
235
|
+
]);
|
|
236
|
+
for (const b of list) {
|
|
237
|
+
expect(b.betweenness).toBeGreaterThan(0);
|
|
238
|
+
expect(b.betweenness).toBeLessThanOrEqual(1);
|
|
239
|
+
}
|
|
240
|
+
expect(list.map((b) => b.betweenness)).toEqual(
|
|
241
|
+
[...list.map((b) => b.betweenness)].sort((x, y) => y - x),
|
|
242
|
+
);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("leaves a leaf out however many neighbours it has", () => {
|
|
246
|
+
const star = bridges(
|
|
247
|
+
estate({ a: ["hub"], b: ["p"], c: ["q"] }, ["b.p -> a.hub", "c.q -> a.hub"]),
|
|
248
|
+
);
|
|
249
|
+
expect(star.map((b) => b.id)).toEqual(["a.hub"]);
|
|
250
|
+
});
|
|
251
|
+
});
|