@shortlink-org/portolan 0.2.4 → 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 (291) hide show
  1. package/README.md +174 -6
  2. package/catalog/enum_test.go +46 -0
  3. package/catalog/evidence_test.go +35 -0
  4. package/catalog/model.go +1213 -0
  5. package/catalog/roundtrip_test.go +210 -0
  6. package/catalog/via_test.go +38 -0
  7. package/cli/init.test.mjs +6 -1
  8. package/cli/portolan.mjs +14 -1
  9. package/cli/portolan.test.mjs +49 -0
  10. package/go.mod +21 -0
  11. package/go.sum +34 -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 +28 -15
  35. package/plugin/describe.go +156 -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 +404 -50
  40. package/plugins/cmd/portolan-http-clients/main.go +19 -0
  41. package/plugins/extract-argocd/options.schema.json +44 -0
  42. package/plugins/extract-celery/extract.py +0 -2
  43. package/plugins/extract-celery/extract_test.py +1 -1
  44. package/plugins/extract-celery/main.py +1 -0
  45. package/plugins/extract-csharp-ddd/README.md +213 -0
  46. package/plugins/extract-csharp-ddd/options.schema.json +55 -0
  47. package/plugins/extract-django/README.md +52 -19
  48. package/plugins/extract-django/choices.py +47 -0
  49. package/plugins/extract-django/domain.py +30 -18
  50. package/plugins/extract-django/extract.py +21 -7
  51. package/plugins/extract-django/extract_test.py +68 -2
  52. package/plugins/extract-django/lifecycle.py +4 -28
  53. package/plugins/extract-django/main.py +1 -0
  54. package/plugins/extract-django/operations.py +1 -1
  55. package/plugins/extract-django/routing_test.py +109 -1
  56. package/plugins/extract-django/rules.py +154 -0
  57. package/plugins/extract-django/rules_test.py +158 -0
  58. package/plugins/extract-django/store.py +1 -1
  59. package/plugins/extract-django/transport.py +101 -55
  60. package/plugins/extract-django/verbs.py +241 -0
  61. package/plugins/extract-go/README.md +47 -0
  62. package/plugins/extract-go-sqs/options.schema.json +21 -0
  63. package/plugins/extract-http-clients/describe.go +20 -0
  64. package/plugins/extract-http-clients/describe_test.go +11 -0
  65. package/plugins/extract-http-clients/extract.go +740 -0
  66. package/plugins/extract-http-clients/extract_test.go +1561 -0
  67. package/plugins/extract-http-clients/main.go +41 -0
  68. package/plugins/extract-java/build/org/portolan/extract/Extract.class +0 -0
  69. package/plugins/extract-java/build/org/portolan/extract/Main.class +0 -0
  70. package/plugins/extract-java/build/org/portolan/extract/Protocol$Builder.class +0 -0
  71. package/plugins/extract-java/build/org/portolan/extract/Protocol$Input.class +0 -0
  72. package/plugins/extract-java/build/org/portolan/extract/Protocol$Options.class +0 -0
  73. package/plugins/extract-k8s/options.schema.json +32 -0
  74. package/plugins/extract-laravel/Cargo.lock +962 -0
  75. package/plugins/extract-laravel/Cargo.toml +20 -0
  76. package/plugins/extract-laravel/README.md +200 -0
  77. package/plugins/extract-laravel/options.schema.json +68 -0
  78. package/plugins/extract-laravel/rustfmt.toml +1 -0
  79. package/plugins/extract-php-ddd/Cargo.lock +972 -0
  80. package/plugins/extract-php-ddd/Cargo.toml +22 -0
  81. package/plugins/extract-php-ddd/README.md +141 -0
  82. package/plugins/extract-php-ddd/options.schema.json +50 -0
  83. package/plugins/extract-php-ddd/rustfmt.toml +1 -0
  84. package/plugins/extract-python-kafka/extract.py +0 -2
  85. package/plugins/extract-python-kafka/extract_test.py +1 -1
  86. package/plugins/extract-python-kafka/main.py +1 -0
  87. package/plugins/extract-terraform/options.schema.json +26 -0
  88. package/plugins/extract-ts/extract.test.ts +2 -2
  89. package/plugins/extract-ts/extract.ts +4 -5
  90. package/plugins/extract-ts/graphql.test.ts +1 -1
  91. package/plugins/extract-ts/main.ts +1 -0
  92. package/plugins/openapi/ids.go +261 -0
  93. package/plugins/openapi/ids_test.go +98 -0
  94. package/plugins/phpscan/Cargo.lock +953 -0
  95. package/plugins/phpscan/Cargo.toml +19 -0
  96. package/plugins/phpscan/rustfmt.toml +1 -0
  97. package/plugins/portolan-go.wasm +0 -0
  98. package/plugins/pyplugin/catalog.py +24 -1
  99. package/plugins/pyplugin/protocol.py +1 -5
  100. package/plugins/verify-otel/options.schema.json +12 -0
  101. package/portolan.json +132 -4
  102. package/rules/builtin.json +218 -0
  103. package/schema/portolan.schema.json +905 -4
  104. package/scripts/README.md +21 -13
  105. package/scripts/catalog-sources.mjs +6 -0
  106. package/scripts/delivery-presets.mjs +57 -14
  107. package/scripts/diff.mjs +5 -1
  108. package/scripts/django-aggregates.test.mjs +58 -0
  109. package/scripts/gen-likec4.mjs +150 -17
  110. package/scripts/gen-likec4.test.mjs +96 -0
  111. package/scripts/gen.mjs +148 -118
  112. package/scripts/gitops-example.test.mjs +108 -0
  113. package/scripts/go-discovery.test.mjs +30 -0
  114. package/scripts/history.mjs +186 -3
  115. package/scripts/history.test.mjs +1 -1
  116. package/scripts/host-plugins/fetch-argocd.mjs +338 -0
  117. package/scripts/host-plugins/fetch-argocd.options.json +49 -0
  118. package/scripts/host-plugins/fetch-argocd.test.mjs +274 -0
  119. package/scripts/host-plugins/fetch-bsr.mjs +1 -0
  120. package/scripts/host-plugins/fetch-csr.mjs +1 -0
  121. package/scripts/host-plugins/fetch-git.mjs +78 -21
  122. package/scripts/host-plugins/fetch-git.test.mjs +62 -8
  123. package/scripts/host-plugins/fetch-k8s.mjs +263 -0
  124. package/scripts/host-plugins/fetch-k8s.options.json +50 -0
  125. package/scripts/host-plugins/fetch-k8s.test.mjs +259 -0
  126. package/scripts/host-plugins/k8s-topology.mjs +183 -0
  127. package/scripts/json-format.mjs +192 -0
  128. package/scripts/json-format.test.mjs +97 -0
  129. package/scripts/local-api.mjs +287 -12
  130. package/scripts/local-api.test.mjs +150 -5
  131. package/scripts/local-discovery.mjs +94 -9
  132. package/scripts/manifest.mjs +14 -3
  133. package/scripts/manifest.test.mjs +24 -0
  134. package/scripts/output-diff.mjs +94 -0
  135. package/scripts/output-diff.test.mjs +36 -0
  136. package/scripts/package-smoke.mjs +62 -4
  137. package/scripts/plugin-host.mjs +45 -3
  138. package/scripts/plugin-host.test.mjs +9 -0
  139. package/scripts/plugin-wasm-worker.mjs +4 -1
  140. package/scripts/plugins-fresh.mjs +97 -0
  141. package/scripts/plugins-fresh.test.mjs +64 -0
  142. package/scripts/provenance.mjs +72 -0
  143. package/scripts/provenance.test.mjs +149 -0
  144. package/scripts/run-builtin.mjs +62 -7
  145. package/scripts/schema.mjs +167 -19
  146. package/scripts/trace-trials.mjs +176 -0
  147. package/scripts/trace-trials.test.mjs +142 -0
  148. package/scripts/warning-policy.mjs +167 -0
  149. package/scripts/warning-policy.test.mjs +93 -0
  150. package/src/app/Breadcrumbs.test.ts +3 -0
  151. package/src/app/Breadcrumbs.tsx +3 -0
  152. package/src/app/CatalogApp.tsx +2 -0
  153. package/src/app/Sidebar.tsx +3 -3
  154. package/src/app/SidebarFooter.tsx +20 -4
  155. package/src/catalog-docs.test.ts +64 -0
  156. package/src/catalog-docs.ts +35 -0
  157. package/src/catalog-error.test.ts +15 -0
  158. package/src/catalog-index.ts +25 -0
  159. package/src/catalog-model.ts +293 -5
  160. package/src/catalog-validation.ts +113 -2
  161. package/src/catalog.test.ts +40 -1
  162. package/src/chat/Starter.tsx +5 -11
  163. package/src/chat/tools.test.ts +27 -0
  164. package/src/chat/tools.ts +5 -9
  165. package/src/components/CatalogStamp.tsx +10 -8
  166. package/src/components/ChannelRows.test.tsx +76 -1
  167. package/src/components/ChannelRows.tsx +223 -60
  168. package/src/components/DeploymentRows.tsx +131 -0
  169. package/src/components/DocsLinks.test.tsx +27 -0
  170. package/src/components/DocsLinks.tsx +56 -0
  171. package/src/components/FieldTree.tsx +5 -2
  172. package/src/components/HTTPDestinationEvidence.test.tsx +23 -0
  173. package/src/components/HTTPDestinationEvidence.tsx +31 -0
  174. package/src/components/Integrations.tsx +1 -1
  175. package/src/components/MachineDocs.tsx +6 -5
  176. package/src/components/MethodRows.tsx +9 -2
  177. package/src/components/PluginIcon.tsx +77 -0
  178. package/src/components/ProblemRow.tsx +173 -153
  179. package/src/components/RelationEvidence.test.tsx +14 -0
  180. package/src/components/RelationEvidence.tsx +53 -0
  181. package/src/components/RuleMarks.tsx +22 -0
  182. package/src/components/ShapeRows.tsx +24 -20
  183. package/src/data.ts +46 -7
  184. package/src/enrich.test.ts +459 -4
  185. package/src/enrich.ts +308 -7
  186. package/src/er/ErCanvas.tsx +218 -12
  187. package/src/er/GroupNode.tsx +57 -0
  188. package/src/er/StoreHeader.tsx +1 -0
  189. package/src/er/layout.test.ts +85 -2
  190. package/src/er/layout.ts +140 -5
  191. package/src/er/spec.test.ts +17 -0
  192. package/src/er/spec.ts +23 -10
  193. package/src/flow/Recordings.test.tsx +52 -0
  194. package/src/flow/Recordings.tsx +236 -0
  195. package/src/flow/StepDetail.tsx +59 -0
  196. package/src/flow/TraceTrial.tsx +419 -0
  197. package/src/flow/evidence.test.ts +16 -0
  198. package/src/flow/evidence.ts +34 -0
  199. package/src/flow/examples.test.ts +33 -0
  200. package/src/flow/examples.ts +37 -0
  201. package/src/flow/outline.test.ts +28 -0
  202. package/src/flow/outline.ts +7 -2
  203. package/src/flow/trace-trial-resume.test.ts +42 -0
  204. package/src/flow/trace-trial-resume.ts +74 -0
  205. package/src/graph/elk.ts +78 -0
  206. package/src/index.css +68 -0
  207. package/src/landing/DraggableReveal.tsx +3 -2
  208. package/src/landing/EvidencePipeline.tsx +105 -0
  209. package/src/landing/LandingPage.tsx +17 -70
  210. package/src/landing/ProductTour.tsx +6 -6
  211. package/src/lib/all-problems.ts +27 -17
  212. package/src/lib/catalog-diff.ts +1 -1
  213. package/src/lib/centrality.test.ts +251 -0
  214. package/src/lib/centrality.ts +232 -0
  215. package/src/lib/confluence.test.ts +37 -0
  216. package/src/lib/confluence.ts +41 -0
  217. package/src/lib/context-color.ts +1 -1
  218. package/src/lib/deployment-drift.ts +22 -0
  219. package/src/lib/derive.ts +43 -69
  220. package/src/lib/django-aggregates.d.mts +9 -0
  221. package/src/lib/django-aggregates.mjs +36 -0
  222. package/src/lib/django-aggregates.test.ts +29 -0
  223. package/src/lib/django-aggregates.ts +5 -0
  224. package/src/lib/environments.test.ts +83 -0
  225. package/src/lib/environments.ts +57 -0
  226. package/src/lib/integration-url.test.ts +30 -0
  227. package/src/lib/integration-url.ts +63 -0
  228. package/src/lib/kafka-ui.ts +3 -45
  229. package/src/lib/local-api.ts +116 -4
  230. package/src/lib/notion.ts +13 -0
  231. package/src/lib/plugin-index.json +2765 -0
  232. package/src/lib/plugins.test.ts +68 -0
  233. package/src/lib/plugins.ts +264 -0
  234. package/src/lib/problem-flows.test.ts +61 -0
  235. package/src/lib/problem-flows.ts +78 -0
  236. package/src/lib/problem-rules-cel.d.mts +43 -0
  237. package/src/lib/problem-rules-cel.mjs +407 -0
  238. package/src/lib/problem-rules.test.ts +287 -0
  239. package/src/lib/problem-rules.ts +271 -0
  240. package/src/lib/problem-subjects.ts +737 -0
  241. package/src/lib/rule-entries.ts +39 -0
  242. package/src/lib/{data-problems.test.ts → rules-data.test.ts} +18 -16
  243. package/src/lib/rules-deploy.test.ts +133 -0
  244. package/src/lib/{problems.test.ts → rules-edges.test.ts} +8 -3
  245. package/src/lib/{proto-problems.test.ts → rules-proto.test.ts} +9 -25
  246. package/src/lib/{wire-problems.test.ts → rules-wire.test.ts} +13 -11
  247. package/src/lib/rules.test.ts +51 -0
  248. package/src/lib/rules.ts +86 -0
  249. package/src/lib/setup-info.test.ts +17 -0
  250. package/src/lib/setup-info.ts +58 -0
  251. package/src/lib/shape.test.ts +32 -0
  252. package/src/lib/shape.ts +30 -6
  253. package/src/lib/tech.ts +16 -0
  254. package/src/lib/trace-project.test.ts +34 -0
  255. package/src/lib/trace-project.ts +50 -0
  256. package/src/lib/use-problems.ts +23 -0
  257. package/src/lib/warnings.test.ts +63 -0
  258. package/src/lib/warnings.ts +260 -0
  259. package/src/likec4/ids.test.ts +6 -2
  260. package/src/likec4/ids.ts +43 -0
  261. package/src/main.tsx +23 -0
  262. package/src/map/ContextMapGraph.tsx +76 -32
  263. package/src/merge-deployments.test.ts +127 -0
  264. package/src/merge.test.ts +82 -0
  265. package/src/merge.ts +189 -18
  266. package/src/pages/AggregatePage.tsx +65 -14
  267. package/src/pages/ContextMap.tsx +45 -3
  268. package/src/pages/ContextPage.tsx +8 -5
  269. package/src/pages/EventPage.tsx +15 -5
  270. package/src/pages/FlowDetail.tsx +23 -2
  271. package/src/pages/GraphPage.tsx +40 -3
  272. package/src/pages/Overview.tsx +152 -12
  273. package/src/pages/PluginIndex.tsx +190 -0
  274. package/src/pages/Problems.tsx +396 -128
  275. package/src/pages/ServicePage.tsx +62 -5
  276. package/src/pages/Settings.tsx +217 -43
  277. package/src/pages/settings/AboutSettings.tsx +8 -1
  278. package/src/pages/settings/DjangoAggregateChoices.tsx +79 -0
  279. package/src/pages/settings/IntegrationsSettings.tsx +63 -17
  280. package/src/pages/settings/RecordingSettings.tsx +138 -0
  281. package/src/pages/settings/RulesSettings.tsx +825 -0
  282. package/src/routes.test.ts +9 -0
  283. package/src/routes.ts +22 -1
  284. package/src/selection/DetailPanel.tsx +15 -0
  285. package/src/virtual-provenance.d.ts +11 -0
  286. package/vite.config.ts +5 -0
  287. package/scripts/vendor-lock.mjs +0 -58
  288. package/scripts/vendor-lock.test.mjs +0 -69
  289. package/src/lib/data-problems.ts +0 -314
  290. package/src/lib/proto-problems.ts +0 -237
  291. package/src/lib/wire-problems.ts +0 -342
package/src/enrich.ts CHANGED
@@ -24,6 +24,7 @@ import type {
24
24
  Flow,
25
25
  FlowNode,
26
26
  RpcCall,
27
+ HTTPDestination,
27
28
  Service,
28
29
  Status,
29
30
  Step,
@@ -60,7 +61,7 @@ export function enrichCatalog(input: Catalog): Enriched {
60
61
  // rather than the name.
61
62
  const catalog = resolveForeignKeys(
62
63
  resolveStoreAccesses(
63
- composeExecutionContinuations(resolveWireNames(input)),
64
+ composeExecutionContinuations(resolveWireNames(resolveHTTPCalls(input))),
64
65
  ),
65
66
  );
66
67
 
@@ -228,6 +229,266 @@ export function enrichCatalog(input: Catalog): Enriched {
228
229
  return { catalog: { ...catalog, contexts }, derived };
229
230
  }
230
231
 
232
+ interface HTTPProvider {
233
+ service: string;
234
+ context: string;
235
+ ref: string;
236
+ method: string;
237
+ path: string;
238
+ /** Set when the manifests, not the route alone, chose this provider. */
239
+ basis?: "kubernetes-host";
240
+ }
241
+
242
+ /**
243
+ * Resolves the protocol-neutral calls emitted by the HTTP client extractor
244
+ * against HTTP routes contributed by server-side extractors.
245
+ *
246
+ * No source fragment can do this on its own: a client repository knows the
247
+ * verb and path but not which of the estate's services answers it, while an
248
+ * OpenAPI or framework fragment knows only what its own service provides.
249
+ * The merged catalog is the first place both facts exist.
250
+ *
251
+ * Proven full paths are authoritative and never fall back to a suffix.
252
+ * Legacy calls can still use a unique suffix because mounted
253
+ * applications commonly see only their local route (`/get-admin-settings`)
254
+ * while the server extractor records the mount too
255
+ * (`/settings/get-admin-settings`). Ambiguous matches and possible self-calls
256
+ * deliberately remain unresolved.
257
+ */
258
+ function resolveHTTPCalls(input: Catalog): Catalog {
259
+ const providers: HTTPProvider[] = [];
260
+ // What the manifests said, where a tree of them was read: the names each
261
+ // service answers on, and the names each caller is configured to dial.
262
+ const hostsByService = new Map<string, Set<string>>();
263
+ const dialsByService = new Map<string, string[]>();
264
+ for (const context of input.contexts) {
265
+ for (const service of context.services) {
266
+ if (service.hosts?.length) {
267
+ hostsByService.set(service.id, new Set(service.hosts));
268
+ }
269
+ if (service.dials?.length) dialsByService.set(service.id, service.dials);
270
+ for (const provided of service.provides) {
271
+ for (const method of provided.methods) {
272
+ // A route with an empty method is mounted but its verb is unknown
273
+ // (extract-django's `Planet.fetch`); the path alone never confirms
274
+ // a link, so it is kept out of the candidates deliberately.
275
+ if (!method.http || !method.http.method) continue;
276
+ providers.push({
277
+ service: service.id,
278
+ context: context.id,
279
+ ref: `${provided.id}/${method.name}`,
280
+ method: method.http.method.toUpperCase(),
281
+ path: method.http.path,
282
+ });
283
+ }
284
+ }
285
+ }
286
+ }
287
+ if (providers.length === 0) return input;
288
+
289
+ const resolvedByCaller = new Map<string, Map<string, HTTPProvider>>();
290
+ const resolve = (caller: string, call: RpcCall): HTTPProvider | undefined => {
291
+ if (call.status !== "unresolved") return undefined;
292
+ const raw = rawHTTPRoute(call.id);
293
+ const route = call.destination?.fullPath
294
+ ? { method: call.destination.method, path: call.destination.fullPath }
295
+ : raw;
296
+ if (!route) return undefined;
297
+
298
+ const candidates = providers.filter(
299
+ (provider) =>
300
+ provider.service !== caller &&
301
+ provider.method === route.method &&
302
+ (call.destination?.fullPath
303
+ ? sameHTTPShape(provider.path, route.path)
304
+ : sameHTTPPath(provider.path, route.path)),
305
+ );
306
+ const exact = uniqueHTTPProviders(
307
+ candidates.filter((provider) => sameHTTPShape(provider.path, route.path)),
308
+ );
309
+ const matches = exact.length > 0 ? exact : uniqueHTTPProviders(candidates);
310
+ if (matches.length === 1) return matches[0];
311
+ if (matches.length < 2) return undefined;
312
+ // Ambiguous by route alone. The manifests may say which of them the
313
+ // caller reaches: the host the call names, or failing that the hosts the
314
+ // caller's workload is configured to dial. They only ever decide between
315
+ // providers of the route; a host never conjures a provider that does not
316
+ // have it.
317
+ const host = callHost(call);
318
+ const dials = dialsByService.get(caller) ?? [];
319
+ const reachable = matches.filter((provider) => {
320
+ const hosts = hostsByService.get(provider.service);
321
+ if (!hosts) return false;
322
+ return host ? hosts.has(host) : dials.some((dial) => hosts.has(dial));
323
+ });
324
+ return reachable.length === 1
325
+ ? { ...reachable[0]!, basis: "kubernetes-host" }
326
+ : undefined;
327
+ };
328
+
329
+ const evidence = (call: Pick<RpcCall, "id" | "source" | "destination">, provider: HTTPProvider): HTTPDestination => {
330
+ const raw = rawHTTPRoute(call.id);
331
+ const destination = call.destination ?? {
332
+ callSite: call.source, endpointExpression: raw?.path ?? call.id,
333
+ method: raw?.method ?? provider.method, localPath: raw?.path,
334
+ };
335
+ return { ...destination, resolution: {
336
+ basis: provider.basis ?? (destination.fullPath ? "full-path" : sameHTTPShape(provider.path, raw?.path ?? "") ? "exact-route" : "unique-suffix"),
337
+ provider: provider.service, route: provider.path,
338
+ } };
339
+ };
340
+ let changed = false;
341
+ const contexts = input.contexts.map((context) => ({
342
+ ...context,
343
+ services: context.services.map((service) => {
344
+ const resolved = new Map<string, HTTPProvider>();
345
+ const mapped = service.consumes.map((call) => {
346
+ const provider = resolve(service.id, call);
347
+ if (!provider) return call;
348
+ changed = true;
349
+ resolved.set(call.id, provider);
350
+ return {
351
+ ...call,
352
+ id: provider.ref,
353
+ destination: evidence(call, provider),
354
+ peer: provider.service,
355
+ status: "declared" as const,
356
+ };
357
+ });
358
+ const consumes = [
359
+ ...new Map(mapped.map((call) => [call.id, call])).values(),
360
+ ];
361
+ if (resolved.size > 0) resolvedByCaller.set(service.id, resolved);
362
+ return resolved.size > 0 ? { ...service, consumes } : service;
363
+ }),
364
+ }));
365
+
366
+ if (!changed) return input;
367
+
368
+ const flows = input.flows.map((flow) => {
369
+ let flowChanged = false;
370
+ const targets = new Map<string, HTTPProvider>();
371
+ const steps = mapSteps(flow.steps, (step) => {
372
+ if (step.kind !== "rpc" || !step.ref) return step;
373
+ const provider = resolvedByCaller.get(step.from)?.get(step.ref);
374
+ if (!provider) return step;
375
+ flowChanged = true;
376
+ targets.set(provider.service, provider);
377
+ return {
378
+ ...step,
379
+ ref: provider.ref,
380
+ destination: evidence({ id: step.ref, source: step.line ?? "", destination: step.destination }, provider),
381
+ to: provider.service,
382
+ status: "declared",
383
+ };
384
+ });
385
+ if (!flowChanged) return flow;
386
+
387
+ const participants = [...flow.participants];
388
+ const seen = new Set(participants.map((participant) => participant.id));
389
+ for (const target of targets.values()) {
390
+ if (seen.has(target.service)) continue;
391
+ seen.add(target.service);
392
+ participants.push({
393
+ id: target.service,
394
+ kind: "service",
395
+ context: target.context,
396
+ });
397
+ }
398
+ return { ...flow, participants, steps };
399
+ });
400
+
401
+ return { ...input, contexts, flows };
402
+ }
403
+
404
+ function rawHTTPRoute(
405
+ id: string,
406
+ ): { method: string; path: string } | undefined {
407
+ // The extractor qualifies a raw call with its destination after " @ "
408
+ // (`http-client/POST /foo @ payments.internal`) so that the same route to two
409
+ // hosts stays two calls; the route itself is what resolves against providers.
410
+ const match = /^http-client\/([A-Z]+)\s+(\/\S*)(?: @ .+)?$/.exec(id);
411
+ return match ? { method: match[1]!, path: match[2]! } : undefined;
412
+ }
413
+
414
+ /**
415
+ * The host a call names, when it names one: the service-discovery alias the
416
+ * extractor recorded, the hostname of a literal base URL, or the destination
417
+ * after " @ " in the id when that is a host rather than an expression. A port
418
+ * is dropped, because a Service's name has none.
419
+ */
420
+ function callHost(call: RpcCall): string | undefined {
421
+ const at = /^http-client\/[A-Z]+\s+\/\S*\s@\s(.+)$/.exec(call.id)?.[1];
422
+ for (const value of [
423
+ call.destination?.serviceDiscoveryAlias,
424
+ call.destination?.baseURL?.value,
425
+ at,
426
+ ]) {
427
+ const host = hostOf(value);
428
+ if (host) return host;
429
+ }
430
+ return undefined;
431
+ }
432
+
433
+ function hostOf(value: string | undefined): string | undefined {
434
+ if (!value) return undefined;
435
+ const trimmed = value.trim();
436
+ if (trimmed.includes("://")) {
437
+ try {
438
+ return new URL(trimmed).hostname || undefined;
439
+ } catch {
440
+ return undefined;
441
+ }
442
+ }
443
+ // Lower case by the cluster's own rule, so that `Config.SupplierURL` - an
444
+ // expression the extractor could not evaluate - is not taken for a host.
445
+ return /^([a-z0-9]([a-z0-9.-]*[a-z0-9])?)(:[0-9]+)?$/.exec(trimmed)?.[1];
446
+ }
447
+
448
+ function uniqueHTTPProviders(providers: HTTPProvider[]): HTTPProvider[] {
449
+ const unique = new Map<string, HTTPProvider>();
450
+ for (const provider of providers) {
451
+ unique.set(`${provider.service}\u0000${provider.ref}`, provider);
452
+ }
453
+ return [...unique.values()];
454
+ }
455
+
456
+ function sameHTTPPath(provided: string, called: string): boolean {
457
+ const provider = httpSegments(provided);
458
+ const call = httpSegments(called);
459
+ if (call.length === 0) return provider.length === 0;
460
+ if (call.length > provider.length) return false;
461
+ const offset = provider.length - call.length;
462
+ for (let index = 0; index < call.length; index += 1) {
463
+ if (!sameHTTPSegment(provider[offset + index]!, call[index]!)) return false;
464
+ }
465
+ return true;
466
+ }
467
+
468
+ function sameHTTPShape(left: string, right: string): boolean {
469
+ const a = httpSegments(left);
470
+ const b = httpSegments(right);
471
+ return (
472
+ a.length === b.length &&
473
+ a.every((segment, index) => sameHTTPSegment(segment, b[index]!))
474
+ );
475
+ }
476
+
477
+ function httpSegments(path: string): string[] {
478
+ const withoutQuery = path.split("?", 1)[0] ?? path;
479
+ return withoutQuery.split("/").filter(Boolean);
480
+ }
481
+
482
+ function sameHTTPSegment(left: string, right: string): boolean {
483
+ return left === right || (isHTTPParameter(left) && isHTTPParameter(right));
484
+ }
485
+
486
+ function isHTTPParameter(segment: string): boolean {
487
+ return (
488
+ segment === "%s" || /^\{[^{}]+\}$/.test(segment) || /^:[^:]+$/.test(segment)
489
+ );
490
+ }
491
+
231
492
  /**
232
493
  * Joins the use-case-side repository call emitted by extract-go to the
233
494
  * concrete Redis client call emitted independently by extract-redis. Method
@@ -273,8 +534,10 @@ function resolveStoreAccesses(input: Catalog): Catalog {
273
534
  ) {
274
535
  return node;
275
536
  }
276
- const method = storeAccess.method.split(".").pop() ?? storeAccess.method;
277
- const matches = byMethod.get(`${storeAccess.store}\u0000${method}`) ?? [];
537
+ const method =
538
+ storeAccess.method.split(".").pop() ?? storeAccess.method;
539
+ const matches =
540
+ byMethod.get(`${storeAccess.store}\u0000${method}`) ?? [];
278
541
  if (matches.length !== 1) return node;
279
542
  changed = true;
280
543
  return {
@@ -866,21 +1129,59 @@ function resolveWireNames(catalog: Catalog): Catalog {
866
1129
  * keeps the raw name, because no extractor sees two schemas. The merge sees
867
1130
  * all of them, and a name exactly one table in the estate answers to is that
868
1131
  * table - which is what turns a dangling key into the crossing it is, on the
869
- * page and on Problems. Two tables of that name resolve to neither.
1132
+ * page and on Problems.
1133
+ *
1134
+ * Two tables of that name are told apart by who the referencing service
1135
+ * talks to: its own store first, then the stores of the services it shares a
1136
+ * context with or calls, or that call it. A shop's delivery service keying
1137
+ * on `orders` means the order service it fetches orders from, not the
1138
+ * `orders` of an unrelated shop that happens to be in the same estate. A
1139
+ * name that is still ambiguous after that resolves to neither.
870
1140
  */
871
1141
  function resolveForeignKeys(catalog: Catalog): Catalog {
872
1142
  const stores = catalog.stores ?? [];
873
1143
  if (stores.length === 0) return catalog;
874
1144
 
875
1145
  const known = new Set<string>();
876
- const byName = new Map<string, string | null>();
1146
+ // name every table id answering to it, in store order.
1147
+ const byName = new Map<string, string[]>();
1148
+ const ownerOfTable = new Map<string, string>();
877
1149
  for (const store of stores) {
878
1150
  for (const table of store.tables) {
879
1151
  known.add(table.id);
880
- byName.set(table.name, byName.has(table.name) ? null : table.id);
1152
+ byName.set(table.name, [...(byName.get(table.name) ?? []), table.id]);
1153
+ ownerOfTable.set(table.id, store.owner);
1154
+ }
1155
+ }
1156
+
1157
+ // Who a service is close to: the services of its context, the ones it
1158
+ // calls, and the ones that call it.
1159
+ const peers = new Map<string, Set<string>>();
1160
+ const near = (a: string, b: string) => {
1161
+ if (!peers.has(a)) peers.set(a, new Set());
1162
+ peers.get(a)!.add(b);
1163
+ };
1164
+ for (const ctx of catalog.contexts) {
1165
+ for (const svc of ctx.services) {
1166
+ for (const other of ctx.services) near(svc.id, other.id);
1167
+ for (const call of svc.consumes) {
1168
+ near(svc.id, call.peer);
1169
+ near(call.peer, svc.id);
1170
+ }
881
1171
  }
882
1172
  }
883
1173
 
1174
+ const resolveName = (name: string, from: string, owner: string): string | null => {
1175
+ const candidates = byName.get(name) ?? [];
1176
+ if (candidates.length === 0) return null;
1177
+ if (candidates.length === 1) return candidates[0]!;
1178
+ const own = candidates.filter((id) => id.startsWith(`${from}.`));
1179
+ if (own.length === 1) return own[0]!;
1180
+ const close = peers.get(owner) ?? new Set<string>();
1181
+ const nearby = candidates.filter((id) => close.has(ownerOfTable.get(id) ?? ""));
1182
+ return nearby.length === 1 ? nearby[0]! : null;
1183
+ };
1184
+
884
1185
  let any = false;
885
1186
  const resolved = stores.map((store) => {
886
1187
  let touched = false;
@@ -888,7 +1189,7 @@ function resolveForeignKeys(catalog: Catalog): Catalog {
888
1189
  let changed = false;
889
1190
  const columns = table.columns.map((column) => {
890
1191
  if (!column.fk || known.has(column.fk.table)) return column;
891
- const found = byName.get(column.fk.table);
1192
+ const found = resolveName(column.fk.table, store.id, store.owner);
892
1193
  if (!found) return column;
893
1194
  changed = true;
894
1195