@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.
Files changed (188) hide show
  1. package/README.md +151 -6
  2. package/catalog/model.go +147 -0
  3. package/catalog/roundtrip_test.go +7 -0
  4. package/cli/portolan.mjs +6 -1
  5. package/go.mod +7 -0
  6. package/go.sum +20 -6
  7. package/package.json +10 -9
  8. package/plugin/describe.go +38 -0
  9. package/plugins/README.md +296 -5
  10. package/plugins/extract-argocd/options.schema.json +44 -0
  11. package/plugins/extract-celery/main.py +1 -0
  12. package/plugins/extract-csharp-ddd/README.md +213 -0
  13. package/plugins/extract-csharp-ddd/options.schema.json +55 -0
  14. package/plugins/extract-django/README.md +13 -2
  15. package/plugins/extract-django/choices.py +47 -0
  16. package/plugins/extract-django/domain.py +2 -1
  17. package/plugins/extract-django/extract_test.py +13 -0
  18. package/plugins/extract-django/lifecycle.py +2 -28
  19. package/plugins/extract-django/main.py +1 -0
  20. package/plugins/extract-django/rules.py +154 -0
  21. package/plugins/extract-django/rules_test.py +158 -0
  22. package/plugins/extract-go-sqs/options.schema.json +21 -0
  23. package/plugins/extract-http-clients/describe.go +5 -4
  24. package/plugins/extract-java/build/org/portolan/extract/Main.class +0 -0
  25. package/plugins/extract-k8s/options.schema.json +32 -0
  26. package/plugins/extract-laravel/Cargo.lock +962 -0
  27. package/plugins/extract-laravel/Cargo.toml +20 -0
  28. package/plugins/extract-laravel/README.md +200 -0
  29. package/plugins/extract-laravel/options.schema.json +68 -0
  30. package/plugins/extract-laravel/rustfmt.toml +1 -0
  31. package/plugins/extract-php-ddd/Cargo.lock +972 -0
  32. package/plugins/extract-php-ddd/Cargo.toml +22 -0
  33. package/plugins/extract-php-ddd/README.md +141 -0
  34. package/plugins/extract-php-ddd/options.schema.json +50 -0
  35. package/plugins/extract-php-ddd/rustfmt.toml +1 -0
  36. package/plugins/extract-python-kafka/main.py +1 -0
  37. package/plugins/extract-terraform/options.schema.json +26 -0
  38. package/plugins/extract-ts/main.ts +1 -0
  39. package/plugins/phpscan/Cargo.lock +953 -0
  40. package/plugins/phpscan/Cargo.toml +19 -0
  41. package/plugins/phpscan/rustfmt.toml +1 -0
  42. package/plugins/portolan-go.wasm +0 -0
  43. package/plugins/pyplugin/catalog.py +24 -1
  44. package/plugins/verify-otel/options.schema.json +12 -0
  45. package/portolan.json +130 -3
  46. package/rules/builtin.json +218 -0
  47. package/schema/portolan.schema.json +880 -13
  48. package/scripts/README.md +3 -1
  49. package/scripts/delivery-presets.mjs +36 -3
  50. package/scripts/gen-likec4.mjs +149 -16
  51. package/scripts/gen-likec4.test.mjs +96 -0
  52. package/scripts/gen.mjs +30 -3
  53. package/scripts/gitops-example.test.mjs +108 -0
  54. package/scripts/host-plugins/fetch-argocd.mjs +338 -0
  55. package/scripts/host-plugins/fetch-argocd.options.json +49 -0
  56. package/scripts/host-plugins/fetch-argocd.test.mjs +274 -0
  57. package/scripts/host-plugins/fetch-bsr.mjs +1 -0
  58. package/scripts/host-plugins/fetch-csr.mjs +1 -0
  59. package/scripts/host-plugins/fetch-git.mjs +1 -0
  60. package/scripts/host-plugins/fetch-k8s.mjs +263 -0
  61. package/scripts/host-plugins/fetch-k8s.options.json +50 -0
  62. package/scripts/host-plugins/fetch-k8s.test.mjs +259 -0
  63. package/scripts/host-plugins/k8s-topology.mjs +183 -0
  64. package/scripts/json-format.mjs +192 -0
  65. package/scripts/json-format.test.mjs +97 -0
  66. package/scripts/local-api.mjs +216 -8
  67. package/scripts/local-api.test.mjs +88 -2
  68. package/scripts/local-discovery.mjs +12 -0
  69. package/scripts/manifest.mjs +10 -1
  70. package/scripts/plugin-host.mjs +23 -1
  71. package/scripts/plugins-fresh.mjs +97 -0
  72. package/scripts/plugins-fresh.test.mjs +64 -0
  73. package/scripts/run-builtin.mjs +26 -5
  74. package/scripts/schema.mjs +138 -19
  75. package/scripts/trace-trials.mjs +176 -0
  76. package/scripts/trace-trials.test.mjs +142 -0
  77. package/scripts/warning-policy.mjs +12 -6
  78. package/scripts/warning-policy.test.mjs +40 -3
  79. package/src/app/Breadcrumbs.test.ts +3 -0
  80. package/src/app/Breadcrumbs.tsx +3 -0
  81. package/src/app/CatalogApp.tsx +2 -0
  82. package/src/app/SidebarFooter.tsx +20 -4
  83. package/src/catalog-index.ts +25 -0
  84. package/src/catalog-model.ts +246 -1
  85. package/src/catalog-validation.ts +104 -2
  86. package/src/catalog.test.ts +40 -1
  87. package/src/components/ChannelRows.test.tsx +76 -1
  88. package/src/components/ChannelRows.tsx +223 -60
  89. package/src/components/DeploymentRows.tsx +131 -0
  90. package/src/components/DocsLinks.test.tsx +27 -0
  91. package/src/components/DocsLinks.tsx +56 -0
  92. package/src/components/FieldTree.tsx +5 -2
  93. package/src/components/HTTPDestinationEvidence.tsx +1 -1
  94. package/src/components/PluginIcon.tsx +77 -0
  95. package/src/components/ProblemRow.tsx +173 -153
  96. package/src/components/RuleMarks.tsx +22 -0
  97. package/src/components/ShapeRows.tsx +24 -20
  98. package/src/data.ts +21 -0
  99. package/src/enrich.test.ts +127 -3
  100. package/src/enrich.ts +104 -6
  101. package/src/er/ErCanvas.tsx +218 -12
  102. package/src/er/GroupNode.tsx +57 -0
  103. package/src/er/StoreHeader.tsx +1 -0
  104. package/src/er/layout.test.ts +85 -2
  105. package/src/er/layout.ts +140 -5
  106. package/src/er/spec.test.ts +17 -0
  107. package/src/er/spec.ts +23 -10
  108. package/src/flow/Recordings.test.tsx +52 -0
  109. package/src/flow/Recordings.tsx +236 -0
  110. package/src/flow/StepDetail.tsx +53 -0
  111. package/src/flow/TraceTrial.tsx +419 -0
  112. package/src/flow/examples.test.ts +33 -0
  113. package/src/flow/examples.ts +37 -0
  114. package/src/flow/outline.test.ts +28 -0
  115. package/src/flow/outline.ts +7 -2
  116. package/src/flow/trace-trial-resume.test.ts +42 -0
  117. package/src/flow/trace-trial-resume.ts +74 -0
  118. package/src/graph/elk.ts +78 -0
  119. package/src/index.css +24 -0
  120. package/src/landing/LandingPage.tsx +15 -11
  121. package/src/landing/ProductTour.tsx +6 -6
  122. package/src/lib/all-problems.ts +27 -17
  123. package/src/lib/centrality.test.ts +251 -0
  124. package/src/lib/centrality.ts +232 -0
  125. package/src/lib/confluence.test.ts +37 -0
  126. package/src/lib/confluence.ts +41 -0
  127. package/src/lib/context-color.ts +1 -1
  128. package/src/lib/deployment-drift.ts +22 -0
  129. package/src/lib/derive.ts +43 -69
  130. package/src/lib/environments.test.ts +83 -0
  131. package/src/lib/environments.ts +57 -0
  132. package/src/lib/integration-url.test.ts +30 -0
  133. package/src/lib/integration-url.ts +63 -0
  134. package/src/lib/kafka-ui.ts +3 -45
  135. package/src/lib/local-api.ts +96 -2
  136. package/src/lib/notion.ts +13 -0
  137. package/src/lib/plugin-index.json +2765 -0
  138. package/src/lib/plugins.test.ts +68 -0
  139. package/src/lib/plugins.ts +264 -0
  140. package/src/lib/problem-flows.test.ts +61 -0
  141. package/src/lib/problem-flows.ts +78 -0
  142. package/src/lib/problem-rules-cel.d.mts +43 -0
  143. package/src/lib/problem-rules-cel.mjs +407 -0
  144. package/src/lib/problem-rules.test.ts +287 -0
  145. package/src/lib/problem-rules.ts +271 -0
  146. package/src/lib/problem-subjects.ts +737 -0
  147. package/src/lib/rule-entries.ts +39 -0
  148. package/src/lib/{data-problems.test.ts → rules-data.test.ts} +18 -16
  149. package/src/lib/rules-deploy.test.ts +133 -0
  150. package/src/lib/{problems.test.ts → rules-edges.test.ts} +8 -3
  151. package/src/lib/{proto-problems.test.ts → rules-proto.test.ts} +9 -25
  152. package/src/lib/{wire-problems.test.ts → rules-wire.test.ts} +13 -11
  153. package/src/lib/rules.test.ts +51 -0
  154. package/src/lib/rules.ts +86 -0
  155. package/src/lib/shape.test.ts +32 -0
  156. package/src/lib/shape.ts +30 -6
  157. package/src/lib/tech.ts +16 -0
  158. package/src/lib/trace-project.test.ts +34 -0
  159. package/src/lib/trace-project.ts +50 -0
  160. package/src/lib/use-problems.ts +23 -0
  161. package/src/lib/warnings.test.ts +9 -0
  162. package/src/lib/warnings.ts +10 -10
  163. package/src/likec4/ids.test.ts +6 -2
  164. package/src/likec4/ids.ts +43 -0
  165. package/src/main.tsx +23 -0
  166. package/src/merge-deployments.test.ts +127 -0
  167. package/src/merge.test.ts +82 -0
  168. package/src/merge.ts +173 -9
  169. package/src/pages/AggregatePage.tsx +57 -7
  170. package/src/pages/ContextMap.tsx +45 -3
  171. package/src/pages/ContextPage.tsx +2 -0
  172. package/src/pages/EventPage.tsx +15 -5
  173. package/src/pages/FlowDetail.tsx +23 -2
  174. package/src/pages/GraphPage.tsx +40 -3
  175. package/src/pages/Overview.tsx +152 -12
  176. package/src/pages/PluginIndex.tsx +190 -0
  177. package/src/pages/Problems.tsx +396 -128
  178. package/src/pages/ServicePage.tsx +58 -2
  179. package/src/pages/Settings.tsx +28 -2
  180. package/src/pages/settings/AboutSettings.tsx +8 -1
  181. package/src/pages/settings/IntegrationsSettings.tsx +63 -17
  182. package/src/pages/settings/RecordingSettings.tsx +138 -0
  183. package/src/pages/settings/RulesSettings.tsx +825 -0
  184. package/src/routes.test.ts +9 -0
  185. package/src/routes.ts +22 -1
  186. package/src/lib/data-problems.ts +0 -314
  187. package/src/lib/proto-problems.ts +0 -237
  188. package/src/lib/wire-problems.ts +0 -342
@@ -0,0 +1,232 @@
1
+ // Which services the estate's paths run through.
2
+ //
3
+ // The dependency graph shows who talks to whom; the context map shows which
4
+ // contexts depend on which. Neither answers a third question a reader asks
5
+ // before touching a service: if this one goes, what stops reaching what? A
6
+ // service with three neighbours can be the only road between two contexts,
7
+ // and a service with twelve can be a leaf of every one of them. Degree does
8
+ // not tell them apart. Betweenness does: the share of shortest paths between
9
+ // every pair of services that pass through this one.
10
+ //
11
+ // The graph is the same two facts the context map is drawn from - an event
12
+ // with a consumer, a call with a peer - collapsed to one undirected edge per
13
+ // pair of services. Undirected, because the question is "who is between",
14
+ // and a reader who asks it means the road, not which way the traffic runs.
15
+ // Unweighted, because a pair joined by one call and a pair joined by twenty
16
+ // are both joined.
17
+ //
18
+ // Two things are counted, and the page says which is which:
19
+ //
20
+ // betweenness - Brandes' number, normalised so 1 means every shortest path
21
+ // between every other pair runs through this service. It ranks.
22
+ // between - the pairs of CONTEXTS for which more than half of the
23
+ // shortest paths between their services run through this one.
24
+ // It is the finding: "auth reaches payments only through cart".
25
+ //
26
+ // Consumers with no service of their own and calls to a peer the catalog does
27
+ // not know are left out. A ghost has no context to be between, and an
28
+ // unresolved peer is a problem the problems page already lists.
29
+
30
+ import type { Catalog } from "../catalog";
31
+
32
+ /** A pair of contexts this service stands between. */
33
+ export interface Brokered {
34
+ /** The two contexts, in catalog order. */
35
+ a: string;
36
+ b: string;
37
+ /** Share of shortest paths between the two that pass through the service. */
38
+ share: number;
39
+ }
40
+
41
+ export interface Centrality {
42
+ id: string;
43
+ context: string;
44
+ /** Distinct services it exchanges anything with. */
45
+ degree: number;
46
+ /** Normalised to 0..1; 0 on an estate of fewer than three services. */
47
+ betweenness: number;
48
+ between: Brokered[];
49
+ }
50
+
51
+ /**
52
+ * The share of a context pair's paths a service must carry MORE than to be
53
+ * said to stand between them. Half, strictly: two equal roads are two roads,
54
+ * and a service on one of them is not what the other side depends on.
55
+ */
56
+ export const BROKER_SHARE = 0.5;
57
+
58
+ export interface ServiceGraph {
59
+ /** Service ids in catalog order. */
60
+ nodes: string[];
61
+ /** Service id -> context id. */
62
+ context: Map<string, string>;
63
+ /** Undirected adjacency, neighbours in first-seen order. */
64
+ adjacent: Map<string, string[]>;
65
+ }
66
+
67
+ /** One undirected edge per pair of catalog services that exchange anything. */
68
+ export function serviceGraph(catalog: Catalog): ServiceGraph {
69
+ const nodes: string[] = [];
70
+ const context = new Map<string, string>();
71
+ for (const ctx of catalog.contexts) {
72
+ for (const service of ctx.services) {
73
+ nodes.push(service.id);
74
+ context.set(service.id, ctx.id);
75
+ }
76
+ }
77
+
78
+ const adjacent = new Map<string, string[]>(nodes.map((id) => [id, []]));
79
+ const join = (a: string, b: string): void => {
80
+ if (a === b || !context.has(a) || !context.has(b)) return;
81
+ const ofA = adjacent.get(a) as string[];
82
+ if (ofA.includes(b)) return;
83
+ ofA.push(b);
84
+ (adjacent.get(b) as string[]).push(a);
85
+ };
86
+
87
+ for (const ctx of catalog.contexts) {
88
+ for (const service of ctx.services) {
89
+ for (const aggregate of service.aggregates) {
90
+ for (const event of aggregate.events) {
91
+ for (const consumer of event.consumers) join(service.id, consumer.service);
92
+ }
93
+ }
94
+ for (const call of service.consumes) join(service.id, call.peer);
95
+ }
96
+ }
97
+ return { nodes, context, adjacent };
98
+ }
99
+
100
+ /**
101
+ * Brandes' algorithm, with the dependency of each source split by the target's
102
+ * context, so the same pass that sums betweenness also says which context
103
+ * pairs a service carries.
104
+ */
105
+ export function centrality(catalog: Catalog): Centrality[] {
106
+ const graph = serviceGraph(catalog);
107
+ const { nodes, context, adjacent } = graph;
108
+ const n = nodes.length;
109
+ const order = catalog.contexts.map((c) => c.id);
110
+ const rank = new Map(order.map((id, i) => [id, i]));
111
+
112
+ const raw = new Map<string, number>(nodes.map((id) => [id, 0]));
113
+ // node -> (source context ~ target context) -> summed dependency.
114
+ const carried = new Map<string, Map<string, number>>(
115
+ nodes.map((id) => [id, new Map()]),
116
+ );
117
+ // source -> target context -> reachable targets (the source excluded).
118
+ const reach = new Map<string, Map<string, number>>();
119
+
120
+ for (const s of nodes) {
121
+ const stack: string[] = [];
122
+ const pred = new Map<string, string[]>(nodes.map((id) => [id, []]));
123
+ const sigma = new Map<string, number>(nodes.map((id) => [id, 0]));
124
+ const dist = new Map<string, number>(nodes.map((id) => [id, -1]));
125
+ sigma.set(s, 1);
126
+ dist.set(s, 0);
127
+ const queue: string[] = [s];
128
+ for (let head = 0; head < queue.length; head += 1) {
129
+ const v = queue[head] as string;
130
+ stack.push(v);
131
+ const dv = dist.get(v) as number;
132
+ for (const w of adjacent.get(v) as string[]) {
133
+ if ((dist.get(w) as number) < 0) {
134
+ dist.set(w, dv + 1);
135
+ queue.push(w);
136
+ }
137
+ if ((dist.get(w) as number) === dv + 1) {
138
+ sigma.set(w, (sigma.get(w) as number) + (sigma.get(v) as number));
139
+ (pred.get(w) as string[]).push(v);
140
+ }
141
+ }
142
+ }
143
+
144
+ const seen = new Map<string, number>();
145
+ for (const t of stack) {
146
+ if (t === s) continue;
147
+ const c = context.get(t) as string;
148
+ seen.set(c, (seen.get(c) ?? 0) + 1);
149
+ }
150
+ reach.set(s, seen);
151
+
152
+ // delta[w][targetContext]: dependency of s on w, over targets in that context.
153
+ const delta = new Map<string, Map<string, number>>(
154
+ nodes.map((id) => [id, new Map()]),
155
+ );
156
+ while (stack.length > 0) {
157
+ const w = stack.pop() as string;
158
+ const dw = delta.get(w) as Map<string, number>;
159
+ const own = context.get(w) as string;
160
+ for (const v of pred.get(w) as string[]) {
161
+ const ratio = (sigma.get(v) as number) / (sigma.get(w) as number);
162
+ const dv = delta.get(v) as Map<string, number>;
163
+ dv.set(own, (dv.get(own) ?? 0) + ratio);
164
+ for (const [c, d] of dw) dv.set(c, (dv.get(c) ?? 0) + ratio * d);
165
+ }
166
+ if (w === s) continue;
167
+ const from = context.get(s) as string;
168
+ const bucket = carried.get(w) as Map<string, number>;
169
+ let sum = 0;
170
+ for (const [to, d] of dw) {
171
+ sum += d;
172
+ const key = pairKey(from, to, rank);
173
+ bucket.set(key, (bucket.get(key) ?? 0) + d);
174
+ }
175
+ raw.set(w, (raw.get(w) as number) + sum);
176
+ }
177
+ }
178
+
179
+ // Ordered (u, v) pairs with u in A and v in B, both reachable, any u and v.
180
+ const pairs = (a: string, b: string): number => {
181
+ let count = 0;
182
+ for (const s of nodes) {
183
+ if (context.get(s) !== a) continue;
184
+ count += reach.get(s)?.get(b) ?? 0;
185
+ }
186
+ return count;
187
+ };
188
+
189
+ const scale = n < 3 ? 0 : 2 / ((n - 1) * (n - 2));
190
+ return nodes.map((id) => {
191
+ const own = context.get(id) as string;
192
+ const between: Brokered[] = [];
193
+ for (const [key, sum] of carried.get(id) as Map<string, number>) {
194
+ const [a, b] = key.split("~") as [string, string];
195
+ // Every (u, v) was counted from both ends; and pairs with the service
196
+ // itself at an end are roads it is on, not roads it is between.
197
+ let total = a === b ? pairs(a, a) / 2 : pairs(a, b);
198
+ if (own === a) total -= reach.get(id)?.get(b) ?? 0;
199
+ else if (own === b) total -= reach.get(id)?.get(a) ?? 0;
200
+ const share = total > 0 ? sum / 2 / total : 0;
201
+ if (a !== b && share > BROKER_SHARE) between.push({ a, b, share });
202
+ }
203
+ between.sort(
204
+ (x, y) =>
205
+ y.share - x.share ||
206
+ (rank.get(x.a) ?? 0) - (rank.get(y.a) ?? 0) ||
207
+ (rank.get(x.b) ?? 0) - (rank.get(y.b) ?? 0),
208
+ );
209
+ return {
210
+ id,
211
+ context: own,
212
+ degree: (adjacent.get(id) as string[]).length,
213
+ betweenness: ((raw.get(id) as number) / 2) * scale,
214
+ between,
215
+ };
216
+ });
217
+ }
218
+
219
+ function pairKey(a: string, b: string, rank: Map<string, number>): string {
220
+ return (rank.get(a) ?? 0) <= (rank.get(b) ?? 0) ? `${a}~${b}` : `${b}~${a}`;
221
+ }
222
+
223
+ /**
224
+ * The services worth a reader's attention: on some shortest path, and the
225
+ * main road between at least one pair of contexts. Highest betweenness first,
226
+ * catalog order between equals.
227
+ */
228
+ export function bridges(catalog: Catalog): Centrality[] {
229
+ return centrality(catalog)
230
+ .filter((c) => c.betweenness > 0 && c.between.length > 0)
231
+ .sort((x, y) => y.betweenness - x.betweenness);
232
+ }
@@ -0,0 +1,37 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { confluenceSearchUrl } from "./confluence";
3
+
4
+ describe("Confluence integration", () => {
5
+ it("searches the whole Cloud site from a site URL", () => {
6
+ expect(confluenceSearchUrl("https://acme.atlassian.net/wiki", "shop.oms")).toBe(
7
+ "https://acme.atlassian.net/wiki/search?text=shop.oms",
8
+ );
9
+ });
10
+
11
+ it("narrows the search to the space a Cloud URL names", () => {
12
+ expect(
13
+ confluenceSearchUrl(
14
+ "https://acme.atlassian.net/wiki/spaces/ARCH/overview",
15
+ "Order management",
16
+ ),
17
+ ).toBe(
18
+ "https://acme.atlassian.net/wiki/search?text=Order+management&spaces=ARCH",
19
+ );
20
+ });
21
+
22
+ it("uses the Server search page behind a context path", () => {
23
+ expect(
24
+ confluenceSearchUrl("https://wiki.example/confluence/display/ARCH", "shop.oms"),
25
+ ).toBe(
26
+ "https://wiki.example/confluence/dosearchsite.action?queryString=shop.oms&where=ARCH",
27
+ );
28
+ expect(confluenceSearchUrl("https://wiki.example/", "shop.oms")).toBe(
29
+ "https://wiki.example/dosearchsite.action?queryString=shop.oms",
30
+ );
31
+ });
32
+
33
+ it("offers nothing before a URL is configured or when it is unsafe", () => {
34
+ expect(confluenceSearchUrl("", "shop.oms")).toBeNull();
35
+ expect(confluenceSearchUrl("javascript:alert(1)", "shop.oms")).toBeNull();
36
+ });
37
+ });
@@ -0,0 +1,41 @@
1
+ // The optional hand-off from a catalogued service or context to the wiki
2
+ // where its team writes.
3
+ //
4
+ // The catalog carries no page ids, so the link is a search by name: the one
5
+ // address Confluence answers for any installation. A site URL searches the
6
+ // whole site; a URL that names a space narrows the search to it. Cloud and
7
+ // Server answer at different paths, told apart by the `/wiki` prefix Cloud
8
+ // always has.
9
+
10
+ import { createIntegrationStore, normalizeIntegrationUrl } from "./integration-url";
11
+
12
+ export const CONFLUENCE_KEY = "portolan.integrations.confluence";
13
+
14
+ /** The search page for `name`, on the site or in the space the URL names. */
15
+ export function confluenceSearchUrl(
16
+ configured: string,
17
+ name: string,
18
+ ): string | null {
19
+ const normalized = normalizeIntegrationUrl(configured);
20
+ if (!normalized) return null;
21
+ const url = new URL(normalized);
22
+ const path = url.pathname;
23
+ const space = path.match(/\/(?:spaces|display)\/([^/]+)/)?.[1];
24
+ const cloud = path.match(/^(.*\/wiki)(?:\/.*)?$/)?.[1];
25
+ const params = new URLSearchParams();
26
+ if (cloud !== undefined) {
27
+ url.pathname = `${cloud}/search`;
28
+ params.set("text", name);
29
+ if (space) params.set("spaces", space);
30
+ } else {
31
+ const base = path.replace(/\/(?:spaces|display)\/.*$/, "").replace(/\/$/, "");
32
+ url.pathname = `${base}/dosearchsite.action`;
33
+ params.set("queryString", name);
34
+ if (space) params.set("where", space);
35
+ }
36
+ url.search = params.toString();
37
+ url.hash = "";
38
+ return url.toString();
39
+ }
40
+
41
+ export const useConfluence = createIntegrationStore(CONFLUENCE_KEY);
@@ -1,7 +1,7 @@
1
1
  import type { CSSProperties } from "react";
2
2
  import { catalog } from "../data";
3
3
 
4
- const CTX_SLOTS = 6;
4
+ export const CTX_SLOTS = 6;
5
5
 
6
6
  /** Context colour is assigned deterministically by index in catalog order. */
7
7
  const order: string[] = catalog.contexts.map((c) => c.id);
@@ -0,0 +1,22 @@
1
+ // The fields the GitOps tree and the deployer disagree on, said in one line
2
+ // each. Read by the deployment rows on a service's page and by the
3
+ // `deployment` subject a rule sees, so the two say the same words.
4
+
5
+ import type { Deployment } from "../catalog";
6
+
7
+ export function driftLines(deployment: Deployment): string[] {
8
+ const drift = deployment.drift;
9
+ if (!drift) return [];
10
+ const lines: string[] = [];
11
+ if (drift.project) lines.push(`project ${drift.project} in the tree, ${deployment.project} deployed`);
12
+ if (drift.cluster) lines.push(`cluster ${drift.cluster} in the tree, ${deployment.cluster} deployed`);
13
+ if (drift.namespace) lines.push(`namespace ${drift.namespace} in the tree, ${deployment.namespace} deployed`);
14
+ if (drift.path) lines.push(`path ${drift.path} in the tree, ${deployment.path} deployed`);
15
+ if (drift.targetRevision) {
16
+ lines.push(`tracks ${drift.targetRevision} in the tree, ${deployment.targetRevision} deployed`);
17
+ }
18
+ if (drift.images?.length) {
19
+ lines.push(`pins ${drift.images.join(", ")} in the tree; running ${(deployment.images ?? []).join(", ") || "nothing"}`);
20
+ }
21
+ return lines;
22
+ }
package/src/lib/derive.ts CHANGED
@@ -2,12 +2,14 @@
2
2
  // so every number a Phase 2 page shows can be asserted in a test.
3
3
 
4
4
  import type {
5
+ Aggregate,
5
6
  Block,
6
7
  BoundedContext,
7
8
  Catalog,
8
9
  Event,
9
10
  Field,
10
11
  Flow,
12
+ Operation,
11
13
  Service,
12
14
  Step,
13
15
  } from "../catalog";
@@ -185,6 +187,38 @@ export interface StepRef {
185
187
  number: number;
186
188
  }
187
189
 
190
+ /**
191
+ * The flows in which an operation runs: one whose step carries the
192
+ * operation's own ref, `<aggregate id>/<operation id>` - a call across a
193
+ * boundary - or whose incoming rpc step is a method the operation says
194
+ * exposes it. Each flow once, at its first such step.
195
+ */
196
+ export function flowsRunning(
197
+ catalog: Catalog,
198
+ service: Service,
199
+ aggregate: Aggregate,
200
+ operation: Operation,
201
+ ): StepRef[] {
202
+ const own = `${aggregate.id}/${operation.id}`;
203
+ const methods = new Set(
204
+ (operation.exposedBy ?? []).flatMap((method) =>
205
+ service.provides.map((provided) => `${provided.id}/${method}`),
206
+ ),
207
+ );
208
+ const out: StepRef[] = [];
209
+ for (const flow of catalog.flows) {
210
+ const steps = walkSteps(flow.steps);
211
+ const i = steps.findIndex(
212
+ (step) =>
213
+ step.ref !== undefined &&
214
+ (step.ref === own || (step.kind === "rpc" && methods.has(step.ref))),
215
+ );
216
+ const hit = steps[i];
217
+ if (hit) out.push({ flow, stepId: hit.id, number: i + 1 });
218
+ }
219
+ return out;
220
+ }
221
+
188
222
  /** Every step across every flow that references an event, with its 1-based number. */
189
223
  export function stepsReferencing(catalog: Catalog, eventId: string): StepRef[] {
190
224
  const out: StepRef[] = [];
@@ -404,29 +438,6 @@ export function usesOfDef(catalog: Catalog, defId: string): DefUse {
404
438
  // Problems
405
439
  // ---------------------------------------------------------------------------
406
440
 
407
- /**
408
- * One edge in the estate that does not land. Two things can be wrong, and they
409
- * are wrong in the same way: a service calls an rpc whose provider is not in
410
- * the catalog, or an event names a consumer that is not either. Both mean the
411
- * chart draws an arrow into open water.
412
- */
413
- export type ProblemKind =
414
- | "rpc"
415
- | "consumer"
416
- | "cross-service-fk"
417
- | "cross-service-lineage"
418
- | "shared-store"
419
- | "persistence-drift"
420
- | "column-type"
421
- | "outbox-payload"
422
- | "proto-missing"
423
- | "proto-drift"
424
- | "shared-channel"
425
- | "channel-undeclared"
426
- | "channel-unpublished"
427
- | "message-encoding"
428
- | "subscription-unresolved";
429
-
430
441
  /**
431
442
  * How wrong a problem is. Two values, not five: an edge either lands somewhere
432
443
  * the catalog knows about or it does not (an error), or the catalog holds two
@@ -435,26 +446,27 @@ export type ProblemKind =
435
446
  */
436
447
  export type Severity = "error" | "warning";
437
448
 
449
+ /**
450
+ * One row of the Problems page: what a rule found, from the near end the
451
+ * reader can see. Every rule is CEL over one subject (rules/builtin.json for
452
+ * the ones the package ships, `problemRules` in the manifest for the estate's
453
+ * own), and `rule` says which; the subject says where `id` leads.
454
+ */
438
455
  export interface Problem {
439
- kind: ProblemKind;
456
+ rule: string;
440
457
  severity: Severity;
441
458
  /** The context that owns the end we can see. */
442
459
  context: string;
443
460
  /** The service that owns the end we can see. */
444
461
  service: string;
445
- /** The call id or the event id - what is on the near end of the edge. */
462
+ /** What is on the near end of the edge: the subject's id. */
446
463
  id: string;
447
- /** The name that resolves to nothing. */
464
+ /** The far end, when the rule names one. */
448
465
  peer: string;
449
466
  note: string | undefined;
450
467
  source: string | undefined;
451
468
  }
452
469
 
453
- /**
454
- * Every unresolved edge, contexts in catalog order. Nothing is scored and
455
- * nothing is ranked: an unresolved consumer is exactly as broken as an
456
- * unresolved call, and sorting them by badness would invent a fact.
457
- */
458
470
  /**
459
471
  * How many edges there were to resolve at all - every rpc call a service makes
460
472
  * and every consumer an event names, whatever their status.
@@ -477,41 +489,3 @@ export function edgeCount(catalog: Catalog): number {
477
489
  return n;
478
490
  }
479
491
 
480
- export function problems(catalog: Catalog): Problem[] {
481
- const out: Problem[] = [];
482
- for (const context of catalog.contexts) {
483
- for (const service of context.services) {
484
- for (const call of service.consumes) {
485
- if (call.status !== "unresolved") continue;
486
- out.push({
487
- kind: "rpc",
488
- severity: "error",
489
- context: context.id,
490
- service: service.id,
491
- id: call.id,
492
- peer: call.peer,
493
- note: call.note,
494
- source: call.source,
495
- });
496
- }
497
- for (const aggregate of service.aggregates) {
498
- for (const event of aggregate.events) {
499
- for (const consumer of event.consumers) {
500
- if (consumer.status !== "unresolved") continue;
501
- out.push({
502
- kind: "consumer",
503
- severity: "error",
504
- context: context.id,
505
- service: service.id,
506
- id: event.id,
507
- peer: consumer.service,
508
- note: consumer.note,
509
- source: undefined,
510
- });
511
- }
512
- }
513
- }
514
- }
515
- }
516
- return out;
517
- }
@@ -0,0 +1,83 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { Catalog, Deployment } from "../catalog";
3
+ import { narrowToEnvironments, servicesDeployedIn } from "./environments";
4
+
5
+ const service = (id: string, path: string) => ({
6
+ id,
7
+ slug: id.split(".")[1] ?? id,
8
+ name: id,
9
+ repo: "github.com/acme/shop",
10
+ path,
11
+ readme: "",
12
+ provides: [],
13
+ consumes: [],
14
+ aggregates: [],
15
+ });
16
+
17
+ const placed = (name: string, environment: string, path: string, extra: Partial<Deployment> = {}): Deployment => ({
18
+ id: `argocd/${name}`,
19
+ name,
20
+ project: "shop",
21
+ environment,
22
+ cluster: "in-cluster",
23
+ namespace: "shop",
24
+ repo: "github.com/acme/shop",
25
+ path,
26
+ targetRevision: "main",
27
+ revision: "a".repeat(40),
28
+ tool: "kustomize",
29
+ url: `https://argocd.example.com/applications/argocd/${name}`,
30
+ ...extra,
31
+ });
32
+
33
+ const catalog: Catalog = {
34
+ generatedAt: "",
35
+ commit: "",
36
+ contexts: [
37
+ {
38
+ id: "shop",
39
+ slug: "shop",
40
+ name: "Shop",
41
+ summary: "",
42
+ services: [service("shop.cart", "services/cart"), service("shop.oms", "services/oms")],
43
+ },
44
+ {
45
+ id: "payments",
46
+ slug: "payments",
47
+ name: "Payments",
48
+ summary: "",
49
+ services: [service("payments.ledger", "services/ledger")],
50
+ },
51
+ ],
52
+ defs: {},
53
+ flows: [],
54
+ adrs: [],
55
+ deployments: [
56
+ placed("cart", "prod", "services/cart/deploy"),
57
+ placed("cart-staging", "staging", "services/cart/deploy"),
58
+ placed("oms", "prod", "services/oms/deploy"),
59
+ // No environment label and no cluster name: placed by the one word left.
60
+ placed("ledger", "", "services/ledger/deploy", { cluster: "" }),
61
+ ],
62
+ };
63
+
64
+ describe("the estate narrowed to an environment", () => {
65
+ it("names the services the snapshot places in the environments asked for", () => {
66
+ expect([...servicesDeployedIn(catalog, new Set(["prod"]))]).toEqual(["shop.cart", "shop.oms"]);
67
+ expect([...servicesDeployedIn(catalog, new Set(["staging"]))]).toEqual(["shop.cart"]);
68
+ expect([...servicesDeployedIn(catalog, new Set(["prod", "staging"]))]).toEqual(["shop.cart", "shop.oms"]);
69
+ expect([...servicesDeployedIn(catalog, new Set(["unplaced"]))]).toEqual(["payments.ledger"]);
70
+ expect(servicesDeployedIn(catalog, new Set(["nowhere"])).size).toBe(0);
71
+ });
72
+
73
+ it("keeps only the deployed services, drops a context left empty, and leaves the rest of the catalog alone", () => {
74
+ const staging = narrowToEnvironments(catalog, new Set(["staging"]));
75
+ expect(staging.contexts.map((c) => [c.id, c.services.map((s) => s.id)])).toEqual([["shop", ["shop.cart"]]]);
76
+ expect(staging.deployments).toBe(catalog.deployments);
77
+ expect(staging.flows).toBe(catalog.flows);
78
+ });
79
+
80
+ it("is the whole estate when no environment is chosen", () => {
81
+ expect(narrowToEnvironments(catalog, new Set())).toBe(catalog);
82
+ });
83
+ });
@@ -0,0 +1,57 @@
1
+ // The estate narrowed to what runs somewhere.
2
+ //
3
+ // A map answers "who leans on whom"; with an environment chosen it answers
4
+ // "who leans on whom IN STAGING" - and the honest way to answer that is to
5
+ // take the services the snapshot does not place there out of the catalog
6
+ // and let the same graph, map and counts run over what is left. A call to
7
+ // a service that is not deployed there is then an edge that leaves the
8
+ // chart, which is what it is. Nothing here is a second graph; it is the
9
+ // first graph over a smaller estate.
10
+
11
+ import type { Catalog } from "../catalog";
12
+ import { allDeployments, deploys, environmentOf } from "../catalog";
13
+
14
+ /** The ids of the services the snapshot places in any of the environments named. */
15
+ export function servicesDeployedIn(
16
+ catalog: Catalog,
17
+ environments: ReadonlySet<string>,
18
+ ): Set<string> {
19
+ const placed = allDeployments(catalog).filter((deployment) =>
20
+ environments.has(environmentOf(deployment)),
21
+ );
22
+ const out = new Set<string>();
23
+ for (const context of catalog.contexts) {
24
+ for (const service of context.services) {
25
+ if (placed.some((deployment) => deploys(deployment, service))) {
26
+ out.add(service.id);
27
+ }
28
+ }
29
+ }
30
+ return out;
31
+ }
32
+
33
+ /**
34
+ * The catalog with only the services deployed in the environments named,
35
+ * and only the contexts that still hold one. No environment named is the
36
+ * whole estate: an empty filter is no filter, as everywhere else in the app.
37
+ *
38
+ * Flows, ADRs, stores and terms are left as they are: a flow through a
39
+ * service that is not in staging is still the flow, and the reader asked
40
+ * about the map, not the archive.
41
+ */
42
+ export function narrowToEnvironments(
43
+ catalog: Catalog,
44
+ environments: ReadonlySet<string>,
45
+ ): Catalog {
46
+ if (environments.size === 0) return catalog;
47
+ const kept = servicesDeployedIn(catalog, environments);
48
+ return {
49
+ ...catalog,
50
+ contexts: catalog.contexts
51
+ .map((context) => ({
52
+ ...context,
53
+ services: context.services.filter((service) => kept.has(service.id)),
54
+ }))
55
+ .filter((context) => context.services.length > 0),
56
+ };
57
+ }
@@ -0,0 +1,30 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { createIntegrationStore, normalizeIntegrationUrl } from "./integration-url";
3
+
4
+ describe("integration URLs", () => {
5
+ it("accepts web URLs and rejects values that cannot be opened safely", () => {
6
+ expect(normalizeIntegrationUrl(" https://docs.example/ ")).toBe(
7
+ "https://docs.example",
8
+ );
9
+ expect(normalizeIntegrationUrl("https://docs.example/wiki#top")).toBe(
10
+ "https://docs.example/wiki",
11
+ );
12
+ expect(normalizeIntegrationUrl("javascript:alert(1)")).toBeNull();
13
+ expect(normalizeIntegrationUrl("not a URL")).toBeNull();
14
+ expect(normalizeIntegrationUrl(" ")).toBe("");
15
+ });
16
+
17
+ it("keeps a normalized value and ignores one that cannot be opened", () => {
18
+ // vitest runs under node, so the store keeps the value for this session
19
+ // only; what is asserted is the behaviour the page sees either way.
20
+ const store = createIntegrationStore("portolan.integrations.test");
21
+ store.getState().setUrl("https://docs.example/");
22
+ expect(store.getState().url).toBe("https://docs.example");
23
+
24
+ store.getState().setUrl("javascript:alert(1)");
25
+ expect(store.getState().url).toBe("https://docs.example");
26
+
27
+ store.getState().setUrl("");
28
+ expect(store.getState().url).toBe("");
29
+ });
30
+ });