@shortlink-org/portolan 0.2.3 → 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 +114 -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/README.md +6 -0
- package/plugins/extract-python-kafka/extract.py +2 -4
- package/plugins/extract-python-kafka/extract_test.py +18 -2
- 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/catalog.py +12 -1
- package/plugins/pyplugin/kafka.py +74 -3
- 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 +79 -21
- package/scripts/gen-likec4.test.mjs +25 -2
- 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/Breadcrumbs.test.ts +4 -0
- package/src/app/Breadcrumbs.tsx +1 -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 +70 -6
- package/src/catalog-stores.test.ts +17 -0
- package/src/catalog-validation.ts +52 -2
- package/src/catalog.test.ts +13 -2
- 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/ChannelRows.messagepack.test.tsx +28 -0
- package/src/components/ChannelRows.test.tsx +54 -0
- package/src/components/ChannelRows.tsx +57 -10
- 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/LifecycleDiagram.tsx +28 -12
- package/src/components/MachineDocs.tsx +6 -5
- package/src/components/MethodRows.tsx +9 -2
- package/src/components/ProblemRow.tsx +4 -0
- package/src/components/RelationEvidence.test.tsx +14 -0
- package/src/components/RelationEvidence.tsx +53 -0
- package/src/components/WhatLinksHere.tsx +6 -4
- package/src/data.ts +25 -7
- package/src/enrich.test.ts +336 -6
- package/src/enrich.ts +206 -3
- package/src/flow/StepDetail.tsx +104 -54
- package/src/flow/answers.test.ts +18 -1
- package/src/flow/answers.ts +37 -8
- 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/backlinks.test.ts +16 -1
- package/src/lib/backlinks.ts +20 -0
- package/src/lib/catalog-diff.test.ts +18 -0
- package/src/lib/catalog-diff.ts +20 -2
- package/src/lib/derive.ts +1 -0
- 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/kafka-ui.test.ts +87 -0
- package/src/lib/kafka-ui.ts +105 -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/lib/wire-problems.test.ts +21 -0
- package/src/lib/wire-problems.ts +62 -1
- package/src/likec4/FlowView.tsx +2 -6
- package/src/likec4/flow-edges.test.ts +64 -1
- package/src/likec4/flow-edges.ts +43 -7
- package/src/likec4/view-index.ts +8 -2
- package/src/map/ContextMapGraph.tsx +76 -32
- package/src/merge.test.ts +23 -0
- package/src/merge.ts +33 -10
- package/src/pages/AggregatePage.tsx +8 -7
- package/src/pages/CatalogFailure.tsx +2 -2
- package/src/pages/ContextPage.tsx +6 -5
- package/src/pages/ServicePage.tsx +4 -3
- package/src/pages/Settings.tsx +200 -44
- package/src/pages/settings/DjangoAggregateChoices.tsx +79 -0
- package/src/pages/settings/IntegrationsSettings.tsx +117 -0
- package/src/routes.test.ts +2 -0
- package/src/routes.ts +2 -1
- package/src/selection/DetailPanel.tsx +61 -1
- 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 {
|
|
@@ -864,14 +1193,13 @@ describe("enrichCatalog: calls from rpc steps", () => {
|
|
|
864
1193
|
expect(() => validateCatalog(catalog)).not.toThrow();
|
|
865
1194
|
});
|
|
866
1195
|
|
|
867
|
-
it("
|
|
868
|
-
// The
|
|
869
|
-
//
|
|
870
|
-
// is on record as calling; after it, shop.oms is.
|
|
1196
|
+
it("keeps a step naming a provided method valid while recording its caller", () => {
|
|
1197
|
+
// The provided method is enough to resolve the flow step. Enrichment adds
|
|
1198
|
+
// the separate fact that shop.oms is one of its callers.
|
|
871
1199
|
const c = estate([
|
|
872
1200
|
flow("a", [step("shop.oms", "shop.pricing", "rpc", { ref: METHOD })]),
|
|
873
1201
|
]);
|
|
874
|
-
expect(() => validateCatalog(c)).toThrow(
|
|
1202
|
+
expect(() => validateCatalog(c)).not.toThrow();
|
|
875
1203
|
expect(() => validateCatalog(enrichCatalog(c).catalog)).not.toThrow();
|
|
876
1204
|
});
|
|
877
1205
|
|
|
@@ -1101,10 +1429,12 @@ describe("enrichCatalog: Redis store accesses", () => {
|
|
|
1101
1429
|
|
|
1102
1430
|
describe("enrichCatalog: the auth fragment", () => {
|
|
1103
1431
|
// The three fragments together, the way the app reads them: the domain one
|
|
1104
|
-
// 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.
|
|
1105
1434
|
const raw = mergeCatalogs(
|
|
1106
1435
|
["domain", "api", "stores"].map((name) => ({
|
|
1107
1436
|
path: `${name}.json`,
|
|
1437
|
+
stamp: { commit: "abc1234", generatedAt: "2026-09-05T00:00:00Z" },
|
|
1108
1438
|
catalog: JSON.parse(
|
|
1109
1439
|
readFileSync(
|
|
1110
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 {
|