@shortlink-org/portolan 0.2.4 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/README.md +23 -0
  2. package/catalog/enum_test.go +46 -0
  3. package/catalog/evidence_test.go +35 -0
  4. package/catalog/model.go +1066 -0
  5. package/catalog/roundtrip_test.go +203 -0
  6. package/catalog/via_test.go +38 -0
  7. package/cli/init.test.mjs +6 -1
  8. package/cli/portolan.mjs +8 -0
  9. package/cli/portolan.test.mjs +49 -0
  10. package/go.mod +14 -0
  11. package/go.sum +20 -0
  12. package/internal/gocall/README.md +19 -0
  13. package/internal/gocall/analyze.go +189 -0
  14. package/internal/gocall/analyze_test.go +107 -0
  15. package/internal/gohttp/analyze.go +2562 -0
  16. package/internal/gohttp/destination.go +373 -0
  17. package/internal/gohttp/endpoints.go +1067 -0
  18. package/internal/gohttp/roots.go +320 -0
  19. package/internal/gohttp/typed.go +96 -0
  20. package/internal/goscan/constants.go +85 -0
  21. package/internal/goscan/goscan_test.go +227 -0
  22. package/internal/goscan/index.go +629 -0
  23. package/internal/goscan/index_test.go +66 -0
  24. package/internal/goscan/names.go +52 -0
  25. package/internal/goscan/parse_test.go +11 -0
  26. package/internal/goscan/source.go +37 -0
  27. package/internal/goscan/tree.go +284 -0
  28. package/internal/goscan/types.go +99 -0
  29. package/internal/wsdl/ids.go +127 -0
  30. package/internal/wsdl/ids_test.go +21 -0
  31. package/internal/wsdl/model.go +70 -0
  32. package/internal/wsdl/parse.go +949 -0
  33. package/internal/wsdl/parse_test.go +170 -0
  34. package/package.json +22 -10
  35. package/plugin/describe.go +118 -0
  36. package/plugin/describe_test.go +114 -0
  37. package/plugin/protocol.go +141 -0
  38. package/plugin/schematest/schematest.go +126 -0
  39. package/plugins/README.md +109 -46
  40. package/plugins/cmd/portolan-http-clients/main.go +19 -0
  41. package/plugins/extract-celery/extract.py +0 -2
  42. package/plugins/extract-celery/extract_test.py +1 -1
  43. package/plugins/extract-django/README.md +39 -17
  44. package/plugins/extract-django/domain.py +28 -17
  45. package/plugins/extract-django/extract.py +21 -7
  46. package/plugins/extract-django/extract_test.py +55 -2
  47. package/plugins/extract-django/lifecycle.py +2 -0
  48. package/plugins/extract-django/operations.py +1 -1
  49. package/plugins/extract-django/routing_test.py +109 -1
  50. package/plugins/extract-django/store.py +1 -1
  51. package/plugins/extract-django/transport.py +101 -55
  52. package/plugins/extract-django/verbs.py +241 -0
  53. package/plugins/extract-go/README.md +47 -0
  54. package/plugins/extract-http-clients/describe.go +19 -0
  55. package/plugins/extract-http-clients/describe_test.go +11 -0
  56. package/plugins/extract-http-clients/extract.go +740 -0
  57. package/plugins/extract-http-clients/extract_test.go +1561 -0
  58. package/plugins/extract-http-clients/main.go +41 -0
  59. package/plugins/extract-java/build/org/portolan/extract/Extract.class +0 -0
  60. package/plugins/extract-java/build/org/portolan/extract/Protocol$Builder.class +0 -0
  61. package/plugins/extract-java/build/org/portolan/extract/Protocol$Input.class +0 -0
  62. package/plugins/extract-java/build/org/portolan/extract/Protocol$Options.class +0 -0
  63. package/plugins/extract-python-kafka/extract.py +0 -2
  64. package/plugins/extract-python-kafka/extract_test.py +1 -1
  65. package/plugins/extract-ts/extract.test.ts +2 -2
  66. package/plugins/extract-ts/extract.ts +4 -5
  67. package/plugins/extract-ts/graphql.test.ts +1 -1
  68. package/plugins/openapi/ids.go +261 -0
  69. package/plugins/openapi/ids_test.go +98 -0
  70. package/plugins/portolan-go.wasm +0 -0
  71. package/plugins/pyplugin/protocol.py +1 -5
  72. package/portolan.json +3 -2
  73. package/schema/portolan.schema.json +34 -0
  74. package/scripts/README.md +18 -12
  75. package/scripts/catalog-sources.mjs +6 -0
  76. package/scripts/delivery-presets.mjs +21 -11
  77. package/scripts/diff.mjs +5 -1
  78. package/scripts/django-aggregates.test.mjs +58 -0
  79. package/scripts/gen-likec4.mjs +1 -1
  80. package/scripts/gen.mjs +118 -115
  81. package/scripts/go-discovery.test.mjs +30 -0
  82. package/scripts/history.mjs +186 -3
  83. package/scripts/history.test.mjs +1 -1
  84. package/scripts/host-plugins/fetch-git.mjs +77 -21
  85. package/scripts/host-plugins/fetch-git.test.mjs +62 -8
  86. package/scripts/local-api.mjs +71 -4
  87. package/scripts/local-api.test.mjs +63 -4
  88. package/scripts/local-discovery.mjs +82 -9
  89. package/scripts/manifest.mjs +5 -3
  90. package/scripts/manifest.test.mjs +24 -0
  91. package/scripts/output-diff.mjs +94 -0
  92. package/scripts/output-diff.test.mjs +36 -0
  93. package/scripts/package-smoke.mjs +62 -4
  94. package/scripts/plugin-host.mjs +22 -2
  95. package/scripts/plugin-host.test.mjs +9 -0
  96. package/scripts/plugin-wasm-worker.mjs +4 -1
  97. package/scripts/provenance.mjs +72 -0
  98. package/scripts/provenance.test.mjs +149 -0
  99. package/scripts/run-builtin.mjs +39 -5
  100. package/scripts/schema.mjs +29 -0
  101. package/scripts/warning-policy.mjs +161 -0
  102. package/scripts/warning-policy.test.mjs +56 -0
  103. package/src/app/Sidebar.tsx +3 -3
  104. package/src/catalog-docs.test.ts +64 -0
  105. package/src/catalog-docs.ts +35 -0
  106. package/src/catalog-error.test.ts +15 -0
  107. package/src/catalog-model.ts +48 -5
  108. package/src/catalog-validation.ts +9 -0
  109. package/src/chat/Starter.tsx +5 -11
  110. package/src/chat/tools.test.ts +27 -0
  111. package/src/chat/tools.ts +5 -9
  112. package/src/components/CatalogStamp.tsx +10 -8
  113. package/src/components/HTTPDestinationEvidence.test.tsx +23 -0
  114. package/src/components/HTTPDestinationEvidence.tsx +31 -0
  115. package/src/components/Integrations.tsx +1 -1
  116. package/src/components/MachineDocs.tsx +6 -5
  117. package/src/components/MethodRows.tsx +9 -2
  118. package/src/components/RelationEvidence.test.tsx +14 -0
  119. package/src/components/RelationEvidence.tsx +53 -0
  120. package/src/data.ts +25 -7
  121. package/src/enrich.test.ts +332 -1
  122. package/src/enrich.ts +206 -3
  123. package/src/flow/StepDetail.tsx +6 -0
  124. package/src/flow/evidence.test.ts +16 -0
  125. package/src/flow/evidence.ts +34 -0
  126. package/src/index.css +44 -0
  127. package/src/landing/DraggableReveal.tsx +3 -2
  128. package/src/landing/EvidencePipeline.tsx +105 -0
  129. package/src/landing/LandingPage.tsx +2 -59
  130. package/src/lib/catalog-diff.ts +1 -1
  131. package/src/lib/django-aggregates.d.mts +9 -0
  132. package/src/lib/django-aggregates.mjs +36 -0
  133. package/src/lib/django-aggregates.test.ts +29 -0
  134. package/src/lib/django-aggregates.ts +5 -0
  135. package/src/lib/local-api.ts +20 -2
  136. package/src/lib/setup-info.test.ts +17 -0
  137. package/src/lib/setup-info.ts +58 -0
  138. package/src/lib/warnings.test.ts +54 -0
  139. package/src/lib/warnings.ts +260 -0
  140. package/src/map/ContextMapGraph.tsx +76 -32
  141. package/src/merge.ts +16 -9
  142. package/src/pages/AggregatePage.tsx +8 -7
  143. package/src/pages/ContextPage.tsx +6 -5
  144. package/src/pages/ServicePage.tsx +4 -3
  145. package/src/pages/Settings.tsx +189 -41
  146. package/src/pages/settings/DjangoAggregateChoices.tsx +79 -0
  147. package/src/selection/DetailPanel.tsx +15 -0
  148. package/src/virtual-provenance.d.ts +11 -0
  149. package/vite.config.ts +5 -0
  150. package/scripts/vendor-lock.mjs +0 -58
  151. package/scripts/vendor-lock.test.mjs +0 -69
@@ -13,6 +13,7 @@ import type {
13
13
  } from "./catalog";
14
14
  import { validateCatalog, walkSteps } from "./catalog";
15
15
  import { enrichCatalog } from "./enrich";
16
+ import { contextMap } from "./lib/context-map";
16
17
  import { mergeCatalogs } from "./merge";
17
18
  import { problems } from "./lib/derive";
18
19
 
@@ -179,6 +180,334 @@ function serviceOf(catalog: Catalog, id: string): Service {
179
180
 
180
181
  // ---------------------------------------------------------------------------
181
182
 
183
+ describe("enrichCatalog: HTTP route correlation", () => {
184
+ function httpProvider(
185
+ slug: string,
186
+ operation: string,
187
+ path: string,
188
+ ): Service {
189
+ return service("shop", slug, {
190
+ provides: [
191
+ {
192
+ id: "api",
193
+ source: `${slug}/openapi.yaml`,
194
+ methods: [
195
+ {
196
+ name: operation,
197
+ doc: "",
198
+ request: "",
199
+ response: "",
200
+ http: { method: "POST", path },
201
+ },
202
+ ],
203
+ },
204
+ ],
205
+ });
206
+ }
207
+
208
+ function httpCaller(
209
+ path: string,
210
+ provides: Service["provides"] = [],
211
+ ): Service {
212
+ return service("shop", "oms", {
213
+ provides,
214
+ consumes: [
215
+ {
216
+ id: `http-client/POST ${path}`,
217
+ peer: "http-peer",
218
+ status: "unresolved",
219
+ source: "client.go:10",
220
+ },
221
+ ],
222
+ });
223
+ }
224
+
225
+ it("maps an outbound route to the unique other service that provides it", () => {
226
+ const callerProvides = httpProvider("oms", "POST /book", "/book").provides;
227
+ const caller = httpCaller("/book", callerProvides);
228
+ const supp = httpProvider("aviasupp", "POST /book", "/book");
229
+ const outbound = flow("book", [
230
+ step("shop.oms", "http-peer", "rpc", {
231
+ ref: "http-client/POST /book",
232
+ label: "POST /book",
233
+ status: "unresolved",
234
+ }),
235
+ ]);
236
+
237
+ const once = enrichCatalog(estate([outbound], [caller, supp])).catalog;
238
+ expect(serviceOf(once, "shop.oms").consumes).toEqual([
239
+ expect.objectContaining({
240
+ id: "api/POST /book",
241
+ peer: "shop.aviasupp",
242
+ status: "declared",
243
+ }),
244
+ ]);
245
+ expect(walkSteps(once.flows[0]!.steps)[0]).toEqual(
246
+ expect.objectContaining({
247
+ ref: "api/POST /book",
248
+ to: "shop.aviasupp",
249
+ status: "declared",
250
+ }),
251
+ );
252
+ expect(once.flows[0]!.participants).toContainEqual({
253
+ id: "shop.aviasupp",
254
+ kind: "service",
255
+ context: "shop",
256
+ });
257
+ expect(enrichCatalog(once).catalog).toEqual(once);
258
+ });
259
+
260
+ it("never confirms a link against a mounted route whose verb is unknown", () => {
261
+ // extract-django keeps a mounted `Planet.fetch` in `provides` with an
262
+ // empty method when no declaration proves the verb; the path alone is a
263
+ // candidate to look at, not a fact to link on.
264
+ const caller = httpCaller("/geo/planet/fetch");
265
+ const geo = service("shop", "geo", {
266
+ provides: [
267
+ {
268
+ id: "shop.geo.geo",
269
+ source: "geo/portolan/openapi.inferred.yaml",
270
+ methods: [
271
+ {
272
+ name: "geo_planet_fetch",
273
+ doc: "",
274
+ request: "",
275
+ response: "",
276
+ http: { method: "", path: "/geo/planet/fetch" },
277
+ },
278
+ ],
279
+ },
280
+ ],
281
+ });
282
+
283
+ const once = enrichCatalog(estate([], [caller, geo])).catalog;
284
+ expect(serviceOf(once, "shop.oms").consumes).toEqual([
285
+ expect.objectContaining({
286
+ id: "http-client/POST /geo/planet/fetch",
287
+ status: "unresolved",
288
+ }),
289
+ ]);
290
+ });
291
+
292
+ it("joins HTTP integrations contributed by independently added projects", () => {
293
+ const caller = service("aviacore", "aviacore", {
294
+ consumes: [
295
+ {
296
+ id: "http-client/POST /book",
297
+ peer: "http-peer",
298
+ status: "unresolved",
299
+ source: "internal/supplier/client.go:42",
300
+ },
301
+ ],
302
+ });
303
+ const provider = service("aviasupp", "aviasupp", {
304
+ provides: [
305
+ {
306
+ id: "api",
307
+ source: "docs/openapi.yaml",
308
+ methods: [
309
+ {
310
+ name: "book_post",
311
+ doc: "",
312
+ request: "",
313
+ response: "",
314
+ http: { method: "POST", path: "/book" },
315
+ },
316
+ ],
317
+ },
318
+ ],
319
+ });
320
+ const fragment = (id: string, owned: Service): Catalog => ({
321
+ generatedAt: "2026-01-01T00:00:00Z",
322
+ commit: id,
323
+ contexts: [context(id, [owned])],
324
+ defs: {},
325
+ flows: [],
326
+ adrs: [],
327
+ });
328
+ const merged = mergeCatalogs([
329
+ {
330
+ path: "vendor/repos/avia/aviacore/portolan/http-clients.json",
331
+ catalog: fragment("aviacore", caller),
332
+ },
333
+ {
334
+ path: "vendor/repos/avia/aviasupp/portolan/api.json",
335
+ catalog: fragment("aviasupp", provider),
336
+ },
337
+ ]).catalog;
338
+
339
+ const resolved = enrichCatalog(merged).catalog;
340
+ const call = serviceOf(resolved, "aviacore.aviacore").consumes[0];
341
+ expect(call).toMatchObject({
342
+ id: "api/book_post",
343
+ peer: "aviasupp.aviasupp",
344
+ status: "declared",
345
+ source: "internal/supplier/client.go:42",
346
+ });
347
+ expect(
348
+ contextMap(resolved).find(
349
+ (relation) => relation.id === "aviacore~aviasupp",
350
+ ),
351
+ ).toMatchObject({
352
+ dependencies: [
353
+ {
354
+ upstream: "aviasupp",
355
+ downstream: "aviacore",
356
+ links: [
357
+ {
358
+ id: "api/book_post",
359
+ from: "aviasupp.aviasupp",
360
+ to: "aviacore.aviacore",
361
+ status: "declared",
362
+ },
363
+ ],
364
+ },
365
+ ],
366
+ });
367
+ expect(() => validateCatalog(resolved)).not.toThrow();
368
+ });
369
+
370
+ it("maps a unique mounted route by its complete segment suffix", () => {
371
+ const caller = httpCaller("/get-admin-settings");
372
+ const admin = httpProvider(
373
+ "aviaadmin",
374
+ "settings_get_admin_settings_post",
375
+ "/settings/get-admin-settings",
376
+ );
377
+ const unrelatedParameterRoute = httpProvider(
378
+ "files",
379
+ "fileByID",
380
+ "/files/{id}",
381
+ );
382
+
383
+ const result = enrichCatalog(
384
+ estate([], [caller, admin, unrelatedParameterRoute]),
385
+ ).catalog;
386
+ expect(serviceOf(result, "shop.oms").consumes[0]).toEqual(
387
+ expect.objectContaining({
388
+ id: "api/settings_get_admin_settings_post",
389
+ peer: "shop.aviaadmin",
390
+ status: "declared",
391
+ }),
392
+ );
393
+ });
394
+
395
+ it("uses a proven full path even when another project has the same suffix", () => {
396
+ const caller = httpCaller("/get-admin-settings");
397
+ caller.consumes[0]!.destination = {
398
+ callSite: "client.go:10", endpointExpression: "c.baseURL + path", method: "POST",
399
+ localPath: "/get-admin-settings", fullPath: "/settings/get-admin-settings",
400
+ baseURL: { expression: "cfg.SettingAddr", configField: "Config.SettingAddr", environmentVariable: "SETTINGS_ADDR", kind: "config-default", value: "http://localhost:8000/settings", source: "config.go:84" },
401
+ join: { expression: "c.baseURL + path", source: "client.go:10" },
402
+ };
403
+ const admin = httpProvider("aviaadmin", "settings", "/settings/get-admin-settings");
404
+ const other = httpProvider("other", "settings", "/other/get-admin-settings");
405
+ const result = enrichCatalog(estate([], [caller, admin, other])).catalog;
406
+ expect(serviceOf(result, "shop.oms").consumes[0]).toMatchObject({ peer: "shop.aviaadmin", destination: { resolution: { basis: "full-path", route: "/settings/get-admin-settings" }, baseURL: { configField: "Config.SettingAddr" } } });
407
+ const missing = enrichCatalog(estate([], [caller, other])).catalog;
408
+ expect(serviceOf(missing, "shop.oms").consumes[0]!.status).toBe("unresolved");
409
+ expect(enrichCatalog(result).catalog).toEqual(result);
410
+ });
411
+
412
+ it("leaves ambiguous routes and possible self-calls unresolved", () => {
413
+ const caller = httpCaller("/book");
414
+ const first = httpProvider("first", "createBook", "/book");
415
+ const second = httpProvider("second", "book", "/book");
416
+
417
+ const ambiguous = enrichCatalog(
418
+ estate([], [caller, first, second]),
419
+ ).catalog;
420
+ expect(serviceOf(ambiguous, "shop.oms").consumes[0]).toEqual(
421
+ caller.consumes[0],
422
+ );
423
+
424
+ const selfOnly = enrichCatalog(
425
+ estate(
426
+ [],
427
+ [httpCaller("/book", httpProvider("oms", "book", "/book").provides)],
428
+ ),
429
+ ).catalog;
430
+ expect(serviceOf(selfOnly, "shop.oms").consumes[0]).toEqual(
431
+ caller.consumes[0],
432
+ );
433
+
434
+ const rootWithoutRootProvider = enrichCatalog(
435
+ estate([], [httpCaller("/"), first]),
436
+ ).catalog;
437
+ expect(serviceOf(rootWithoutRootProvider, "shop.oms").consumes[0]).toEqual(
438
+ httpCaller("/").consumes[0],
439
+ );
440
+ });
441
+
442
+ // PORTOLAN-19: the extractor qualifies a raw call with its destination, so
443
+ // the same route addressed to two hosts arrives as two consumes. They must
444
+ // stay two - resolved to two providers when the estate has them, or two
445
+ // unresolved calls when it does not - never one glued entry.
446
+ it("keeps the same route to two hosts as two calls", () => {
447
+ const toHost = (host: string): Service["consumes"][number] => ({
448
+ id: `http-client/POST /foo @ ${host}`,
449
+ peer: host.replace(".", "-"),
450
+ status: "unresolved",
451
+ source: "client.go:11",
452
+ destination: {
453
+ callSite: "client.go:11",
454
+ endpointExpression: "path",
455
+ method: "POST",
456
+ localPath: "/foo",
457
+ fullPath: "/foo",
458
+ serviceDiscoveryAlias: host,
459
+ },
460
+ });
461
+ const caller = service("shop", "oms", {
462
+ consumes: [toHost("payments.internal"), toHost("ledger.internal")],
463
+ });
464
+ const payments = httpProvider("payments", "POST /foo", "/foo");
465
+ const ledger = httpProvider("ledger", "POST /foo", "/foo");
466
+
467
+ const ambiguous = enrichCatalog(
468
+ estate([], [caller, payments, ledger]),
469
+ ).catalog;
470
+ expect(
471
+ serviceOf(ambiguous, "shop.oms").consumes.map((call) => [
472
+ call.id,
473
+ call.peer,
474
+ call.status,
475
+ ]),
476
+ ).toEqual([
477
+ ["http-client/POST /foo @ payments.internal", "payments-internal", "unresolved"],
478
+ ["http-client/POST /foo @ ledger.internal", "ledger-internal", "unresolved"],
479
+ ]);
480
+ });
481
+
482
+ it("resolves a destination-qualified route like a bare one", () => {
483
+ const caller = service("shop", "oms", {
484
+ consumes: [
485
+ {
486
+ id: "http-client/POST /book @ Config.SupplierURL",
487
+ peer: "http-peer",
488
+ status: "unresolved",
489
+ source: "client.go:10",
490
+ },
491
+ ],
492
+ });
493
+ const supp = httpProvider("aviasupp", "POST /book", "/book");
494
+ const once = enrichCatalog(estate([], [caller, supp])).catalog;
495
+ expect(serviceOf(once, "shop.oms").consumes).toEqual([
496
+ expect.objectContaining({
497
+ id: "api/POST /book",
498
+ peer: "shop.aviasupp",
499
+ status: "declared",
500
+ destination: expect.objectContaining({
501
+ resolution: { basis: "exact-route", provider: "shop.aviasupp", route: "/book" },
502
+ }),
503
+ }),
504
+ ]);
505
+ expect(enrichCatalog(once).catalog).toEqual(once);
506
+ });
507
+ });
508
+
509
+ // ---------------------------------------------------------------------------
510
+
182
511
  describe("enrichCatalog: asynchronous outbound continuations", () => {
183
512
  function outbound(slug: string, entrypoint: string, path: string): Flow {
184
513
  return {
@@ -1100,10 +1429,12 @@ describe("enrichCatalog: Redis store accesses", () => {
1100
1429
 
1101
1430
  describe("enrichCatalog: the auth fragment", () => {
1102
1431
  // The three fragments together, the way the app reads them: the domain one
1103
- // alone names endpoints the api one declares.
1432
+ // alone names endpoints the api one declares. A fragment carries no stamp
1433
+ // of its own (portolan.0010); the reader hands the history's over beside it.
1104
1434
  const raw = mergeCatalogs(
1105
1435
  ["domain", "api", "stores"].map((name) => ({
1106
1436
  path: `${name}.json`,
1437
+ stamp: { commit: "abc1234", generatedAt: "2026-09-05T00:00:00Z" },
1107
1438
  catalog: JSON.parse(
1108
1439
  readFileSync(
1109
1440
  new URL(`../examples/auth/portolan/${name}.json`, import.meta.url),
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,206 @@ 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
+ }
239
+
240
+ /**
241
+ * Resolves the protocol-neutral calls emitted by the HTTP client extractor
242
+ * against HTTP routes contributed by server-side extractors.
243
+ *
244
+ * No source fragment can do this on its own: a client repository knows the
245
+ * verb and path but not which of the estate's services answers it, while an
246
+ * OpenAPI or framework fragment knows only what its own service provides.
247
+ * The merged catalog is the first place both facts exist.
248
+ *
249
+ * Proven full paths are authoritative and never fall back to a suffix.
250
+ * Legacy calls can still use a unique suffix because mounted
251
+ * applications commonly see only their local route (`/get-admin-settings`)
252
+ * while the server extractor records the mount too
253
+ * (`/settings/get-admin-settings`). Ambiguous matches and possible self-calls
254
+ * deliberately remain unresolved.
255
+ */
256
+ function resolveHTTPCalls(input: Catalog): Catalog {
257
+ const providers: HTTPProvider[] = [];
258
+ for (const context of input.contexts) {
259
+ for (const service of context.services) {
260
+ for (const provided of service.provides) {
261
+ for (const method of provided.methods) {
262
+ // A route with an empty method is mounted but its verb is unknown
263
+ // (extract-django's `Planet.fetch`); the path alone never confirms
264
+ // a link, so it is kept out of the candidates deliberately.
265
+ if (!method.http || !method.http.method) continue;
266
+ providers.push({
267
+ service: service.id,
268
+ context: context.id,
269
+ ref: `${provided.id}/${method.name}`,
270
+ method: method.http.method.toUpperCase(),
271
+ path: method.http.path,
272
+ });
273
+ }
274
+ }
275
+ }
276
+ }
277
+ if (providers.length === 0) return input;
278
+
279
+ const resolvedByCaller = new Map<string, Map<string, HTTPProvider>>();
280
+ const resolve = (caller: string, call: RpcCall): HTTPProvider | undefined => {
281
+ if (call.status !== "unresolved") return undefined;
282
+ const raw = rawHTTPRoute(call.id);
283
+ const route = call.destination?.fullPath
284
+ ? { method: call.destination.method, path: call.destination.fullPath }
285
+ : raw;
286
+ if (!route) return undefined;
287
+
288
+ const candidates = providers.filter(
289
+ (provider) =>
290
+ provider.service !== caller &&
291
+ provider.method === route.method &&
292
+ (call.destination?.fullPath
293
+ ? sameHTTPShape(provider.path, route.path)
294
+ : sameHTTPPath(provider.path, route.path)),
295
+ );
296
+ const exact = uniqueHTTPProviders(
297
+ candidates.filter((provider) => sameHTTPShape(provider.path, route.path)),
298
+ );
299
+ const matches = exact.length > 0 ? exact : uniqueHTTPProviders(candidates);
300
+ return matches.length === 1 ? matches[0] : undefined;
301
+ };
302
+
303
+ const evidence = (call: Pick<RpcCall, "id" | "source" | "destination">, provider: HTTPProvider): HTTPDestination => {
304
+ const raw = rawHTTPRoute(call.id);
305
+ const destination = call.destination ?? {
306
+ callSite: call.source, endpointExpression: raw?.path ?? call.id,
307
+ method: raw?.method ?? provider.method, localPath: raw?.path,
308
+ };
309
+ return { ...destination, resolution: {
310
+ basis: destination.fullPath ? "full-path" : sameHTTPShape(provider.path, raw?.path ?? "") ? "exact-route" : "unique-suffix",
311
+ provider: provider.service, route: provider.path,
312
+ } };
313
+ };
314
+ let changed = false;
315
+ const contexts = input.contexts.map((context) => ({
316
+ ...context,
317
+ services: context.services.map((service) => {
318
+ const resolved = new Map<string, HTTPProvider>();
319
+ const mapped = service.consumes.map((call) => {
320
+ const provider = resolve(service.id, call);
321
+ if (!provider) return call;
322
+ changed = true;
323
+ resolved.set(call.id, provider);
324
+ return {
325
+ ...call,
326
+ id: provider.ref,
327
+ destination: evidence(call, provider),
328
+ peer: provider.service,
329
+ status: "declared" as const,
330
+ };
331
+ });
332
+ const consumes = [
333
+ ...new Map(mapped.map((call) => [call.id, call])).values(),
334
+ ];
335
+ if (resolved.size > 0) resolvedByCaller.set(service.id, resolved);
336
+ return resolved.size > 0 ? { ...service, consumes } : service;
337
+ }),
338
+ }));
339
+
340
+ if (!changed) return input;
341
+
342
+ const flows = input.flows.map((flow) => {
343
+ let flowChanged = false;
344
+ const targets = new Map<string, HTTPProvider>();
345
+ const steps = mapSteps(flow.steps, (step) => {
346
+ if (step.kind !== "rpc" || !step.ref) return step;
347
+ const provider = resolvedByCaller.get(step.from)?.get(step.ref);
348
+ if (!provider) return step;
349
+ flowChanged = true;
350
+ targets.set(provider.service, provider);
351
+ return {
352
+ ...step,
353
+ ref: provider.ref,
354
+ destination: evidence({ id: step.ref, source: step.line ?? "", destination: step.destination }, provider),
355
+ to: provider.service,
356
+ status: "declared",
357
+ };
358
+ });
359
+ if (!flowChanged) return flow;
360
+
361
+ const participants = [...flow.participants];
362
+ const seen = new Set(participants.map((participant) => participant.id));
363
+ for (const target of targets.values()) {
364
+ if (seen.has(target.service)) continue;
365
+ seen.add(target.service);
366
+ participants.push({
367
+ id: target.service,
368
+ kind: "service",
369
+ context: target.context,
370
+ });
371
+ }
372
+ return { ...flow, participants, steps };
373
+ });
374
+
375
+ return { ...input, contexts, flows };
376
+ }
377
+
378
+ function rawHTTPRoute(
379
+ id: string,
380
+ ): { method: string; path: string } | undefined {
381
+ // The extractor qualifies a raw call with its destination after " @ "
382
+ // (`http-client/POST /foo @ payments.internal`) so that the same route to two
383
+ // hosts stays two calls; the route itself is what resolves against providers.
384
+ const match = /^http-client\/([A-Z]+)\s+(\/\S*)(?: @ .+)?$/.exec(id);
385
+ return match ? { method: match[1]!, path: match[2]! } : undefined;
386
+ }
387
+
388
+ function uniqueHTTPProviders(providers: HTTPProvider[]): HTTPProvider[] {
389
+ const unique = new Map<string, HTTPProvider>();
390
+ for (const provider of providers) {
391
+ unique.set(`${provider.service}\u0000${provider.ref}`, provider);
392
+ }
393
+ return [...unique.values()];
394
+ }
395
+
396
+ function sameHTTPPath(provided: string, called: string): boolean {
397
+ const provider = httpSegments(provided);
398
+ const call = httpSegments(called);
399
+ if (call.length === 0) return provider.length === 0;
400
+ if (call.length > provider.length) return false;
401
+ const offset = provider.length - call.length;
402
+ for (let index = 0; index < call.length; index += 1) {
403
+ if (!sameHTTPSegment(provider[offset + index]!, call[index]!)) return false;
404
+ }
405
+ return true;
406
+ }
407
+
408
+ function sameHTTPShape(left: string, right: string): boolean {
409
+ const a = httpSegments(left);
410
+ const b = httpSegments(right);
411
+ return (
412
+ a.length === b.length &&
413
+ a.every((segment, index) => sameHTTPSegment(segment, b[index]!))
414
+ );
415
+ }
416
+
417
+ function httpSegments(path: string): string[] {
418
+ const withoutQuery = path.split("?", 1)[0] ?? path;
419
+ return withoutQuery.split("/").filter(Boolean);
420
+ }
421
+
422
+ function sameHTTPSegment(left: string, right: string): boolean {
423
+ return left === right || (isHTTPParameter(left) && isHTTPParameter(right));
424
+ }
425
+
426
+ function isHTTPParameter(segment: string): boolean {
427
+ return (
428
+ segment === "%s" || /^\{[^{}]+\}$/.test(segment) || /^:[^:]+$/.test(segment)
429
+ );
430
+ }
431
+
231
432
  /**
232
433
  * Joins the use-case-side repository call emitted by extract-go to the
233
434
  * concrete Redis client call emitted independently by extract-redis. Method
@@ -273,8 +474,10 @@ function resolveStoreAccesses(input: Catalog): Catalog {
273
474
  ) {
274
475
  return node;
275
476
  }
276
- const method = storeAccess.method.split(".").pop() ?? storeAccess.method;
277
- const matches = byMethod.get(`${storeAccess.store}\u0000${method}`) ?? [];
477
+ const method =
478
+ storeAccess.method.split(".").pop() ?? storeAccess.method;
479
+ const matches =
480
+ byMethod.get(`${storeAccess.store}\u0000${method}`) ?? [];
278
481
  if (matches.length !== 1) return node;
279
482
  changed = true;
280
483
  return {
@@ -1,3 +1,6 @@
1
+ import { RelationEvidencePanel } from "../components/RelationEvidence";
2
+ import { stepRelationEvidence } from "./evidence";
3
+ import { HTTPDestinationEvidence } from "../components/HTTPDestinationEvidence";
1
4
  import { useMemo } from "react";
2
5
  import { Link } from "react-router";
3
6
  import { AlertTriangle, FileCode2 } from "lucide-react";
@@ -190,6 +193,8 @@ function RpcDetail({ step, flow }: { step: Step; flow: Flow }) {
190
193
  </div>
191
194
  </header>
192
195
 
196
+ {step.destination ? <HTTPDestinationEvidence destination={step.destination} /> : null}
197
+
193
198
  <DetailSection title="Contract">
194
199
  <dl className="grid grid-cols-[minmax(7rem,auto)_minmax(0,1fr)] gap-x-4 gap-y-2">
195
200
  {answer ? (
@@ -718,6 +723,7 @@ export function StepDetailBody({ step, flow }: { step: Step; flow: Flow }) {
718
723
  return (
719
724
  <>
720
725
  <ExecutionContext step={step} flow={flow} />
726
+ <RelationEvidencePanel items={stepRelationEvidence(index, step)} renderSource={(where) => <SourceWhere where={where} flow={flow} structured />} />
721
727
 
722
728
  {/* A decision that names this step's event is the reason the step
723
729
  looks the way it does. It belongs next to the step, not three
@@ -0,0 +1,16 @@
1
+ import { expect, it } from "vitest";
2
+ import { buildIndex, type Catalog, type Step } from "../catalog";
3
+ import { stepRelationEvidence } from "./evidence";
4
+
5
+ const index = buildIndex({ generatedAt: "", commit: "", contexts: [], defs: {}, flows: [], adrs: [] } as Catalog);
6
+ it("gives legacy call, event, RPC and response steps an honest source explanation", () => {
7
+ for (const kind of ["call", "event", "rpc", "response"] as const) {
8
+ const step: Step = { type: "step", id: "s1", from: "a", to: "b", kind, status: "declared", line: "handler.go:8" };
9
+ expect(stepRelationEvidence(index, step)).toEqual([{ kind: "call-site", rule: "source-expression", source: "handler.go:8", symbol: undefined }]);
10
+ }
11
+ });
12
+ it("keeps unresolved status and deduplicates recorded evidence", () => {
13
+ const evidence = { kind: "call-site" as const, rule: "source-expression", source: "handler.go:8" };
14
+ const step: Step = { type: "step", id: "s1", from: "a", to: "b", kind: "call", status: "unresolved", line: evidence.source, evidence: [evidence, evidence] };
15
+ expect(stepRelationEvidence(index, step).map((item) => item.kind)).toEqual(["call-site", "unresolved"]);
16
+ });
@@ -0,0 +1,34 @@
1
+ import type { CatalogIndex, RelationEvidence, Step } from "../catalog";
2
+ import { stepRpcContract } from "./answers";
3
+
4
+ /** Adapt older fragments to the same explanation without inventing a path. */
5
+ export function stepRelationEvidence(index: CatalogIndex, step: Step): RelationEvidence[] {
6
+ const items = [...(step.evidence ?? [])];
7
+ if (step.line && !items.some((item) => item.kind === "call-site" && item.source === step.line)) {
8
+ items.push({ kind: "call-site", rule: "source-expression", source: step.line, symbol: step.label });
9
+ }
10
+ const contract = stepRpcContract(index, step);
11
+ if (contract) items.push({ kind: "contract", rule: "catalog-contract", source: contract.provided.source, symbol: contract.id });
12
+ if (step.kind === "event" && step.ref) {
13
+ const event = index.eventById.get(step.ref);
14
+ const version = event?.versions.at(-1);
15
+ if (version) items.push({ kind: "contract", rule: "event-declaration", source: version.source, symbol: step.ref });
16
+ }
17
+ if (step.storeAccess?.source) items.push({ kind: "binding", rule: "repository-sql-call", source: step.storeAccess.source, symbol: step.storeAccess.method });
18
+ if (step.destination?.resolution) {
19
+ const resolution = step.destination.resolution;
20
+ items.push({ kind: "resolution", rule: resolution.basis, symbol: `${resolution.provider} ${resolution.route}` });
21
+ }
22
+ if (step.continuesAt) items.push({ kind: "resolution", rule: "continuation-entrypoint", symbol: step.continuesAt });
23
+ if (step.kind === "response" && step.replyTo) items.push({ kind: "resolution", rule: "response-to-request", symbol: step.replyTo });
24
+ if (step.status === "unresolved" && !items.some((item) => item.kind === "unresolved")) {
25
+ items.push({ kind: "unresolved", rule: "unresolved-target", symbol: step.ref ?? step.label });
26
+ }
27
+ const seen = new Set<string>();
28
+ return items.filter((item) => {
29
+ const key = JSON.stringify(item);
30
+ if (seen.has(key)) return false;
31
+ seen.add(key);
32
+ return true;
33
+ });
34
+ }