@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.
- package/README.md +23 -0
- package/catalog/enum_test.go +46 -0
- package/catalog/evidence_test.go +35 -0
- package/catalog/model.go +1066 -0
- package/catalog/roundtrip_test.go +203 -0
- package/catalog/via_test.go +38 -0
- package/cli/init.test.mjs +6 -1
- package/cli/portolan.mjs +8 -0
- package/cli/portolan.test.mjs +49 -0
- package/go.mod +14 -0
- package/go.sum +20 -0
- package/internal/gocall/README.md +19 -0
- package/internal/gocall/analyze.go +189 -0
- package/internal/gocall/analyze_test.go +107 -0
- package/internal/gohttp/analyze.go +2562 -0
- package/internal/gohttp/destination.go +373 -0
- package/internal/gohttp/endpoints.go +1067 -0
- package/internal/gohttp/roots.go +320 -0
- package/internal/gohttp/typed.go +96 -0
- package/internal/goscan/constants.go +85 -0
- package/internal/goscan/goscan_test.go +227 -0
- package/internal/goscan/index.go +629 -0
- package/internal/goscan/index_test.go +66 -0
- package/internal/goscan/names.go +52 -0
- package/internal/goscan/parse_test.go +11 -0
- package/internal/goscan/source.go +37 -0
- package/internal/goscan/tree.go +284 -0
- package/internal/goscan/types.go +99 -0
- package/internal/wsdl/ids.go +127 -0
- package/internal/wsdl/ids_test.go +21 -0
- package/internal/wsdl/model.go +70 -0
- package/internal/wsdl/parse.go +949 -0
- package/internal/wsdl/parse_test.go +170 -0
- package/package.json +22 -10
- package/plugin/describe.go +118 -0
- package/plugin/describe_test.go +114 -0
- package/plugin/protocol.go +141 -0
- package/plugin/schematest/schematest.go +126 -0
- package/plugins/README.md +109 -46
- package/plugins/cmd/portolan-http-clients/main.go +19 -0
- package/plugins/extract-celery/extract.py +0 -2
- package/plugins/extract-celery/extract_test.py +1 -1
- package/plugins/extract-django/README.md +39 -17
- package/plugins/extract-django/domain.py +28 -17
- package/plugins/extract-django/extract.py +21 -7
- package/plugins/extract-django/extract_test.py +55 -2
- package/plugins/extract-django/lifecycle.py +2 -0
- package/plugins/extract-django/operations.py +1 -1
- package/plugins/extract-django/routing_test.py +109 -1
- package/plugins/extract-django/store.py +1 -1
- package/plugins/extract-django/transport.py +101 -55
- package/plugins/extract-django/verbs.py +241 -0
- package/plugins/extract-go/README.md +47 -0
- package/plugins/extract-http-clients/describe.go +19 -0
- package/plugins/extract-http-clients/describe_test.go +11 -0
- package/plugins/extract-http-clients/extract.go +740 -0
- package/plugins/extract-http-clients/extract_test.go +1561 -0
- package/plugins/extract-http-clients/main.go +41 -0
- package/plugins/extract-java/build/org/portolan/extract/Extract.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Protocol$Builder.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Protocol$Input.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Protocol$Options.class +0 -0
- package/plugins/extract-python-kafka/extract.py +0 -2
- package/plugins/extract-python-kafka/extract_test.py +1 -1
- package/plugins/extract-ts/extract.test.ts +2 -2
- package/plugins/extract-ts/extract.ts +4 -5
- package/plugins/extract-ts/graphql.test.ts +1 -1
- package/plugins/openapi/ids.go +261 -0
- package/plugins/openapi/ids_test.go +98 -0
- package/plugins/portolan-go.wasm +0 -0
- package/plugins/pyplugin/protocol.py +1 -5
- package/portolan.json +3 -2
- package/schema/portolan.schema.json +34 -0
- package/scripts/README.md +18 -12
- package/scripts/catalog-sources.mjs +6 -0
- package/scripts/delivery-presets.mjs +21 -11
- package/scripts/diff.mjs +5 -1
- package/scripts/django-aggregates.test.mjs +58 -0
- package/scripts/gen-likec4.mjs +1 -1
- package/scripts/gen.mjs +118 -115
- package/scripts/go-discovery.test.mjs +30 -0
- package/scripts/history.mjs +186 -3
- package/scripts/history.test.mjs +1 -1
- package/scripts/host-plugins/fetch-git.mjs +77 -21
- package/scripts/host-plugins/fetch-git.test.mjs +62 -8
- package/scripts/local-api.mjs +71 -4
- package/scripts/local-api.test.mjs +63 -4
- package/scripts/local-discovery.mjs +82 -9
- package/scripts/manifest.mjs +5 -3
- package/scripts/manifest.test.mjs +24 -0
- package/scripts/output-diff.mjs +94 -0
- package/scripts/output-diff.test.mjs +36 -0
- package/scripts/package-smoke.mjs +62 -4
- package/scripts/plugin-host.mjs +22 -2
- package/scripts/plugin-host.test.mjs +9 -0
- package/scripts/plugin-wasm-worker.mjs +4 -1
- package/scripts/provenance.mjs +72 -0
- package/scripts/provenance.test.mjs +149 -0
- package/scripts/run-builtin.mjs +39 -5
- package/scripts/schema.mjs +29 -0
- package/scripts/warning-policy.mjs +161 -0
- package/scripts/warning-policy.test.mjs +56 -0
- package/src/app/Sidebar.tsx +3 -3
- package/src/catalog-docs.test.ts +64 -0
- package/src/catalog-docs.ts +35 -0
- package/src/catalog-error.test.ts +15 -0
- package/src/catalog-model.ts +48 -5
- package/src/catalog-validation.ts +9 -0
- package/src/chat/Starter.tsx +5 -11
- package/src/chat/tools.test.ts +27 -0
- package/src/chat/tools.ts +5 -9
- package/src/components/CatalogStamp.tsx +10 -8
- package/src/components/HTTPDestinationEvidence.test.tsx +23 -0
- package/src/components/HTTPDestinationEvidence.tsx +31 -0
- package/src/components/Integrations.tsx +1 -1
- package/src/components/MachineDocs.tsx +6 -5
- package/src/components/MethodRows.tsx +9 -2
- package/src/components/RelationEvidence.test.tsx +14 -0
- package/src/components/RelationEvidence.tsx +53 -0
- package/src/data.ts +25 -7
- package/src/enrich.test.ts +332 -1
- package/src/enrich.ts +206 -3
- package/src/flow/StepDetail.tsx +6 -0
- package/src/flow/evidence.test.ts +16 -0
- package/src/flow/evidence.ts +34 -0
- package/src/index.css +44 -0
- package/src/landing/DraggableReveal.tsx +3 -2
- package/src/landing/EvidencePipeline.tsx +105 -0
- package/src/landing/LandingPage.tsx +2 -59
- package/src/lib/catalog-diff.ts +1 -1
- package/src/lib/django-aggregates.d.mts +9 -0
- package/src/lib/django-aggregates.mjs +36 -0
- package/src/lib/django-aggregates.test.ts +29 -0
- package/src/lib/django-aggregates.ts +5 -0
- package/src/lib/local-api.ts +20 -2
- package/src/lib/setup-info.test.ts +17 -0
- package/src/lib/setup-info.ts +58 -0
- package/src/lib/warnings.test.ts +54 -0
- package/src/lib/warnings.ts +260 -0
- package/src/map/ContextMapGraph.tsx +76 -32
- package/src/merge.ts +16 -9
- package/src/pages/AggregatePage.tsx +8 -7
- package/src/pages/ContextPage.tsx +6 -5
- package/src/pages/ServicePage.tsx +4 -3
- package/src/pages/Settings.tsx +189 -41
- package/src/pages/settings/DjangoAggregateChoices.tsx +79 -0
- package/src/selection/DetailPanel.tsx +15 -0
- package/src/virtual-provenance.d.ts +11 -0
- package/vite.config.ts +5 -0
- package/scripts/vendor-lock.mjs +0 -58
- package/scripts/vendor-lock.test.mjs +0 -69
package/src/enrich.test.ts
CHANGED
|
@@ -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 =
|
|
277
|
-
|
|
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 {
|
package/src/flow/StepDetail.tsx
CHANGED
|
@@ -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
|
+
}
|