@shortlink-org/portolan 0.2.4 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (291) hide show
  1. package/README.md +174 -6
  2. package/catalog/enum_test.go +46 -0
  3. package/catalog/evidence_test.go +35 -0
  4. package/catalog/model.go +1213 -0
  5. package/catalog/roundtrip_test.go +210 -0
  6. package/catalog/via_test.go +38 -0
  7. package/cli/init.test.mjs +6 -1
  8. package/cli/portolan.mjs +14 -1
  9. package/cli/portolan.test.mjs +49 -0
  10. package/go.mod +21 -0
  11. package/go.sum +34 -0
  12. package/internal/gocall/README.md +19 -0
  13. package/internal/gocall/analyze.go +189 -0
  14. package/internal/gocall/analyze_test.go +107 -0
  15. package/internal/gohttp/analyze.go +2562 -0
  16. package/internal/gohttp/destination.go +373 -0
  17. package/internal/gohttp/endpoints.go +1067 -0
  18. package/internal/gohttp/roots.go +320 -0
  19. package/internal/gohttp/typed.go +96 -0
  20. package/internal/goscan/constants.go +85 -0
  21. package/internal/goscan/goscan_test.go +227 -0
  22. package/internal/goscan/index.go +629 -0
  23. package/internal/goscan/index_test.go +66 -0
  24. package/internal/goscan/names.go +52 -0
  25. package/internal/goscan/parse_test.go +11 -0
  26. package/internal/goscan/source.go +37 -0
  27. package/internal/goscan/tree.go +284 -0
  28. package/internal/goscan/types.go +99 -0
  29. package/internal/wsdl/ids.go +127 -0
  30. package/internal/wsdl/ids_test.go +21 -0
  31. package/internal/wsdl/model.go +70 -0
  32. package/internal/wsdl/parse.go +949 -0
  33. package/internal/wsdl/parse_test.go +170 -0
  34. package/package.json +28 -15
  35. package/plugin/describe.go +156 -0
  36. package/plugin/describe_test.go +114 -0
  37. package/plugin/protocol.go +141 -0
  38. package/plugin/schematest/schematest.go +126 -0
  39. package/plugins/README.md +404 -50
  40. package/plugins/cmd/portolan-http-clients/main.go +19 -0
  41. package/plugins/extract-argocd/options.schema.json +44 -0
  42. package/plugins/extract-celery/extract.py +0 -2
  43. package/plugins/extract-celery/extract_test.py +1 -1
  44. package/plugins/extract-celery/main.py +1 -0
  45. package/plugins/extract-csharp-ddd/README.md +213 -0
  46. package/plugins/extract-csharp-ddd/options.schema.json +55 -0
  47. package/plugins/extract-django/README.md +52 -19
  48. package/plugins/extract-django/choices.py +47 -0
  49. package/plugins/extract-django/domain.py +30 -18
  50. package/plugins/extract-django/extract.py +21 -7
  51. package/plugins/extract-django/extract_test.py +68 -2
  52. package/plugins/extract-django/lifecycle.py +4 -28
  53. package/plugins/extract-django/main.py +1 -0
  54. package/plugins/extract-django/operations.py +1 -1
  55. package/plugins/extract-django/routing_test.py +109 -1
  56. package/plugins/extract-django/rules.py +154 -0
  57. package/plugins/extract-django/rules_test.py +158 -0
  58. package/plugins/extract-django/store.py +1 -1
  59. package/plugins/extract-django/transport.py +101 -55
  60. package/plugins/extract-django/verbs.py +241 -0
  61. package/plugins/extract-go/README.md +47 -0
  62. package/plugins/extract-go-sqs/options.schema.json +21 -0
  63. package/plugins/extract-http-clients/describe.go +20 -0
  64. package/plugins/extract-http-clients/describe_test.go +11 -0
  65. package/plugins/extract-http-clients/extract.go +740 -0
  66. package/plugins/extract-http-clients/extract_test.go +1561 -0
  67. package/plugins/extract-http-clients/main.go +41 -0
  68. package/plugins/extract-java/build/org/portolan/extract/Extract.class +0 -0
  69. package/plugins/extract-java/build/org/portolan/extract/Main.class +0 -0
  70. package/plugins/extract-java/build/org/portolan/extract/Protocol$Builder.class +0 -0
  71. package/plugins/extract-java/build/org/portolan/extract/Protocol$Input.class +0 -0
  72. package/plugins/extract-java/build/org/portolan/extract/Protocol$Options.class +0 -0
  73. package/plugins/extract-k8s/options.schema.json +32 -0
  74. package/plugins/extract-laravel/Cargo.lock +962 -0
  75. package/plugins/extract-laravel/Cargo.toml +20 -0
  76. package/plugins/extract-laravel/README.md +200 -0
  77. package/plugins/extract-laravel/options.schema.json +68 -0
  78. package/plugins/extract-laravel/rustfmt.toml +1 -0
  79. package/plugins/extract-php-ddd/Cargo.lock +972 -0
  80. package/plugins/extract-php-ddd/Cargo.toml +22 -0
  81. package/plugins/extract-php-ddd/README.md +141 -0
  82. package/plugins/extract-php-ddd/options.schema.json +50 -0
  83. package/plugins/extract-php-ddd/rustfmt.toml +1 -0
  84. package/plugins/extract-python-kafka/extract.py +0 -2
  85. package/plugins/extract-python-kafka/extract_test.py +1 -1
  86. package/plugins/extract-python-kafka/main.py +1 -0
  87. package/plugins/extract-terraform/options.schema.json +26 -0
  88. package/plugins/extract-ts/extract.test.ts +2 -2
  89. package/plugins/extract-ts/extract.ts +4 -5
  90. package/plugins/extract-ts/graphql.test.ts +1 -1
  91. package/plugins/extract-ts/main.ts +1 -0
  92. package/plugins/openapi/ids.go +261 -0
  93. package/plugins/openapi/ids_test.go +98 -0
  94. package/plugins/phpscan/Cargo.lock +953 -0
  95. package/plugins/phpscan/Cargo.toml +19 -0
  96. package/plugins/phpscan/rustfmt.toml +1 -0
  97. package/plugins/portolan-go.wasm +0 -0
  98. package/plugins/pyplugin/catalog.py +24 -1
  99. package/plugins/pyplugin/protocol.py +1 -5
  100. package/plugins/verify-otel/options.schema.json +12 -0
  101. package/portolan.json +132 -4
  102. package/rules/builtin.json +218 -0
  103. package/schema/portolan.schema.json +905 -4
  104. package/scripts/README.md +21 -13
  105. package/scripts/catalog-sources.mjs +6 -0
  106. package/scripts/delivery-presets.mjs +57 -14
  107. package/scripts/diff.mjs +5 -1
  108. package/scripts/django-aggregates.test.mjs +58 -0
  109. package/scripts/gen-likec4.mjs +150 -17
  110. package/scripts/gen-likec4.test.mjs +96 -0
  111. package/scripts/gen.mjs +148 -118
  112. package/scripts/gitops-example.test.mjs +108 -0
  113. package/scripts/go-discovery.test.mjs +30 -0
  114. package/scripts/history.mjs +186 -3
  115. package/scripts/history.test.mjs +1 -1
  116. package/scripts/host-plugins/fetch-argocd.mjs +338 -0
  117. package/scripts/host-plugins/fetch-argocd.options.json +49 -0
  118. package/scripts/host-plugins/fetch-argocd.test.mjs +274 -0
  119. package/scripts/host-plugins/fetch-bsr.mjs +1 -0
  120. package/scripts/host-plugins/fetch-csr.mjs +1 -0
  121. package/scripts/host-plugins/fetch-git.mjs +78 -21
  122. package/scripts/host-plugins/fetch-git.test.mjs +62 -8
  123. package/scripts/host-plugins/fetch-k8s.mjs +263 -0
  124. package/scripts/host-plugins/fetch-k8s.options.json +50 -0
  125. package/scripts/host-plugins/fetch-k8s.test.mjs +259 -0
  126. package/scripts/host-plugins/k8s-topology.mjs +183 -0
  127. package/scripts/json-format.mjs +192 -0
  128. package/scripts/json-format.test.mjs +97 -0
  129. package/scripts/local-api.mjs +287 -12
  130. package/scripts/local-api.test.mjs +150 -5
  131. package/scripts/local-discovery.mjs +94 -9
  132. package/scripts/manifest.mjs +14 -3
  133. package/scripts/manifest.test.mjs +24 -0
  134. package/scripts/output-diff.mjs +94 -0
  135. package/scripts/output-diff.test.mjs +36 -0
  136. package/scripts/package-smoke.mjs +62 -4
  137. package/scripts/plugin-host.mjs +45 -3
  138. package/scripts/plugin-host.test.mjs +9 -0
  139. package/scripts/plugin-wasm-worker.mjs +4 -1
  140. package/scripts/plugins-fresh.mjs +97 -0
  141. package/scripts/plugins-fresh.test.mjs +64 -0
  142. package/scripts/provenance.mjs +72 -0
  143. package/scripts/provenance.test.mjs +149 -0
  144. package/scripts/run-builtin.mjs +62 -7
  145. package/scripts/schema.mjs +167 -19
  146. package/scripts/trace-trials.mjs +176 -0
  147. package/scripts/trace-trials.test.mjs +142 -0
  148. package/scripts/warning-policy.mjs +167 -0
  149. package/scripts/warning-policy.test.mjs +93 -0
  150. package/src/app/Breadcrumbs.test.ts +3 -0
  151. package/src/app/Breadcrumbs.tsx +3 -0
  152. package/src/app/CatalogApp.tsx +2 -0
  153. package/src/app/Sidebar.tsx +3 -3
  154. package/src/app/SidebarFooter.tsx +20 -4
  155. package/src/catalog-docs.test.ts +64 -0
  156. package/src/catalog-docs.ts +35 -0
  157. package/src/catalog-error.test.ts +15 -0
  158. package/src/catalog-index.ts +25 -0
  159. package/src/catalog-model.ts +293 -5
  160. package/src/catalog-validation.ts +113 -2
  161. package/src/catalog.test.ts +40 -1
  162. package/src/chat/Starter.tsx +5 -11
  163. package/src/chat/tools.test.ts +27 -0
  164. package/src/chat/tools.ts +5 -9
  165. package/src/components/CatalogStamp.tsx +10 -8
  166. package/src/components/ChannelRows.test.tsx +76 -1
  167. package/src/components/ChannelRows.tsx +223 -60
  168. package/src/components/DeploymentRows.tsx +131 -0
  169. package/src/components/DocsLinks.test.tsx +27 -0
  170. package/src/components/DocsLinks.tsx +56 -0
  171. package/src/components/FieldTree.tsx +5 -2
  172. package/src/components/HTTPDestinationEvidence.test.tsx +23 -0
  173. package/src/components/HTTPDestinationEvidence.tsx +31 -0
  174. package/src/components/Integrations.tsx +1 -1
  175. package/src/components/MachineDocs.tsx +6 -5
  176. package/src/components/MethodRows.tsx +9 -2
  177. package/src/components/PluginIcon.tsx +77 -0
  178. package/src/components/ProblemRow.tsx +173 -153
  179. package/src/components/RelationEvidence.test.tsx +14 -0
  180. package/src/components/RelationEvidence.tsx +53 -0
  181. package/src/components/RuleMarks.tsx +22 -0
  182. package/src/components/ShapeRows.tsx +24 -20
  183. package/src/data.ts +46 -7
  184. package/src/enrich.test.ts +459 -4
  185. package/src/enrich.ts +308 -7
  186. package/src/er/ErCanvas.tsx +218 -12
  187. package/src/er/GroupNode.tsx +57 -0
  188. package/src/er/StoreHeader.tsx +1 -0
  189. package/src/er/layout.test.ts +85 -2
  190. package/src/er/layout.ts +140 -5
  191. package/src/er/spec.test.ts +17 -0
  192. package/src/er/spec.ts +23 -10
  193. package/src/flow/Recordings.test.tsx +52 -0
  194. package/src/flow/Recordings.tsx +236 -0
  195. package/src/flow/StepDetail.tsx +59 -0
  196. package/src/flow/TraceTrial.tsx +419 -0
  197. package/src/flow/evidence.test.ts +16 -0
  198. package/src/flow/evidence.ts +34 -0
  199. package/src/flow/examples.test.ts +33 -0
  200. package/src/flow/examples.ts +37 -0
  201. package/src/flow/outline.test.ts +28 -0
  202. package/src/flow/outline.ts +7 -2
  203. package/src/flow/trace-trial-resume.test.ts +42 -0
  204. package/src/flow/trace-trial-resume.ts +74 -0
  205. package/src/graph/elk.ts +78 -0
  206. package/src/index.css +68 -0
  207. package/src/landing/DraggableReveal.tsx +3 -2
  208. package/src/landing/EvidencePipeline.tsx +105 -0
  209. package/src/landing/LandingPage.tsx +17 -70
  210. package/src/landing/ProductTour.tsx +6 -6
  211. package/src/lib/all-problems.ts +27 -17
  212. package/src/lib/catalog-diff.ts +1 -1
  213. package/src/lib/centrality.test.ts +251 -0
  214. package/src/lib/centrality.ts +232 -0
  215. package/src/lib/confluence.test.ts +37 -0
  216. package/src/lib/confluence.ts +41 -0
  217. package/src/lib/context-color.ts +1 -1
  218. package/src/lib/deployment-drift.ts +22 -0
  219. package/src/lib/derive.ts +43 -69
  220. package/src/lib/django-aggregates.d.mts +9 -0
  221. package/src/lib/django-aggregates.mjs +36 -0
  222. package/src/lib/django-aggregates.test.ts +29 -0
  223. package/src/lib/django-aggregates.ts +5 -0
  224. package/src/lib/environments.test.ts +83 -0
  225. package/src/lib/environments.ts +57 -0
  226. package/src/lib/integration-url.test.ts +30 -0
  227. package/src/lib/integration-url.ts +63 -0
  228. package/src/lib/kafka-ui.ts +3 -45
  229. package/src/lib/local-api.ts +116 -4
  230. package/src/lib/notion.ts +13 -0
  231. package/src/lib/plugin-index.json +2765 -0
  232. package/src/lib/plugins.test.ts +68 -0
  233. package/src/lib/plugins.ts +264 -0
  234. package/src/lib/problem-flows.test.ts +61 -0
  235. package/src/lib/problem-flows.ts +78 -0
  236. package/src/lib/problem-rules-cel.d.mts +43 -0
  237. package/src/lib/problem-rules-cel.mjs +407 -0
  238. package/src/lib/problem-rules.test.ts +287 -0
  239. package/src/lib/problem-rules.ts +271 -0
  240. package/src/lib/problem-subjects.ts +737 -0
  241. package/src/lib/rule-entries.ts +39 -0
  242. package/src/lib/{data-problems.test.ts → rules-data.test.ts} +18 -16
  243. package/src/lib/rules-deploy.test.ts +133 -0
  244. package/src/lib/{problems.test.ts → rules-edges.test.ts} +8 -3
  245. package/src/lib/{proto-problems.test.ts → rules-proto.test.ts} +9 -25
  246. package/src/lib/{wire-problems.test.ts → rules-wire.test.ts} +13 -11
  247. package/src/lib/rules.test.ts +51 -0
  248. package/src/lib/rules.ts +86 -0
  249. package/src/lib/setup-info.test.ts +17 -0
  250. package/src/lib/setup-info.ts +58 -0
  251. package/src/lib/shape.test.ts +32 -0
  252. package/src/lib/shape.ts +30 -6
  253. package/src/lib/tech.ts +16 -0
  254. package/src/lib/trace-project.test.ts +34 -0
  255. package/src/lib/trace-project.ts +50 -0
  256. package/src/lib/use-problems.ts +23 -0
  257. package/src/lib/warnings.test.ts +63 -0
  258. package/src/lib/warnings.ts +260 -0
  259. package/src/likec4/ids.test.ts +6 -2
  260. package/src/likec4/ids.ts +43 -0
  261. package/src/main.tsx +23 -0
  262. package/src/map/ContextMapGraph.tsx +76 -32
  263. package/src/merge-deployments.test.ts +127 -0
  264. package/src/merge.test.ts +82 -0
  265. package/src/merge.ts +189 -18
  266. package/src/pages/AggregatePage.tsx +65 -14
  267. package/src/pages/ContextMap.tsx +45 -3
  268. package/src/pages/ContextPage.tsx +8 -5
  269. package/src/pages/EventPage.tsx +15 -5
  270. package/src/pages/FlowDetail.tsx +23 -2
  271. package/src/pages/GraphPage.tsx +40 -3
  272. package/src/pages/Overview.tsx +152 -12
  273. package/src/pages/PluginIndex.tsx +190 -0
  274. package/src/pages/Problems.tsx +396 -128
  275. package/src/pages/ServicePage.tsx +62 -5
  276. package/src/pages/Settings.tsx +217 -43
  277. package/src/pages/settings/AboutSettings.tsx +8 -1
  278. package/src/pages/settings/DjangoAggregateChoices.tsx +79 -0
  279. package/src/pages/settings/IntegrationsSettings.tsx +63 -17
  280. package/src/pages/settings/RecordingSettings.tsx +138 -0
  281. package/src/pages/settings/RulesSettings.tsx +825 -0
  282. package/src/routes.test.ts +9 -0
  283. package/src/routes.ts +22 -1
  284. package/src/selection/DetailPanel.tsx +15 -0
  285. package/src/virtual-provenance.d.ts +11 -0
  286. package/vite.config.ts +5 -0
  287. package/scripts/vendor-lock.mjs +0 -58
  288. package/scripts/vendor-lock.test.mjs +0 -69
  289. package/src/lib/data-problems.ts +0 -314
  290. package/src/lib/proto-problems.ts +0 -237
  291. package/src/lib/wire-problems.ts +0 -342
package/src/data.ts CHANGED
@@ -32,6 +32,11 @@ import type {
32
32
  SourceStamp,
33
33
  } from "./merge";
34
34
  import manifestJson from "../portolan.json";
35
+ import { catalogDocs } from "./catalog-docs";
36
+ // When each source last changed, as the checkout's history says - served by
37
+ // scripts/provenance.mjs at build time, because a fragment carries no
38
+ // provenance of its own (portolan.0010) and a browser cannot ask git.
39
+ import provenance from "virtual:portolan-provenance";
35
40
 
36
41
  const manifest = manifestJson as CatalogProfileManifest & { sources: string[] };
37
42
  export const catalogProfiles: CatalogProfile[] = profilesFromManifest(manifest);
@@ -56,6 +61,24 @@ export const activeCatalogProfile = catalogProfileNamed(
56
61
  requestedProfileFromUrl(),
57
62
  );
58
63
 
64
+ export const activeCatalogDocs = catalogDocs(manifestJson, activeCatalogProfile.id, import.meta.env.BASE_URL);
65
+
66
+ // The manifest is imported, so a change to it would reload the page - and
67
+ // the page changes it: keeping a recording widens a verify step, adding a
68
+ // project appends one. Neither touches what this module derived from it (the
69
+ // profiles: which sources make which catalog), and in local mode the setup
70
+ // itself is read from the server, not from here. So the update is taken
71
+ // without a reload unless the profiles did change, in which case the reload
72
+ // is the right answer and is asked for.
73
+ if (import.meta.hot) {
74
+ const profileKey = (m: CatalogProfileManifest & { sources?: string[] }) =>
75
+ JSON.stringify(profilesFromManifest(m).map((p) => [p.id, p.sources, p.contexts]));
76
+ import.meta.hot.accept("../portolan.json", (next) => {
77
+ const incoming = (next as { default?: CatalogProfileManifest & { sources?: string[] } } | undefined)?.default;
78
+ if (!incoming || profileKey(incoming) !== profileKey(manifest)) import.meta.hot?.invalidate();
79
+ });
80
+ }
81
+
59
82
  /**
60
83
  * Where sources are looked for. The patterns are written out because
61
84
  * import.meta.glob resolves at build time and needs literals - and because
@@ -71,6 +94,7 @@ export const SOURCE_GLOBS = [
71
94
  "examples/*/*/*/portolan/*.json",
72
95
  "vendor/repos/*/*/git.repo.json",
73
96
  "vendor/repos/**/portolan/*.json",
97
+ "examples/argocd/argocd.apps.json",
74
98
  ] as const;
75
99
 
76
100
  const modules: Record<string, unknown> = {
@@ -96,6 +120,10 @@ const modules: Record<string, unknown> = {
96
120
  eager: true,
97
121
  import: "default",
98
122
  }),
123
+ ...import.meta.glob("../examples/argocd/argocd.apps.json", {
124
+ eager: true,
125
+ import: "default",
126
+ }),
99
127
  };
100
128
 
101
129
  /** What the app draws when the catalog cannot be trusted: nothing. */
@@ -120,15 +148,26 @@ interface Loaded {
120
148
 
121
149
  function load(): Loaded {
122
150
  const sources: CatalogSource[] = Object.entries(modules)
123
- .map(([path, catalog]) => ({
151
+ .map(([key, catalog]) => {
124
152
  // Vite keys a glob by its pattern-relative path; the leading ../ is an
125
153
  // artefact of this file's location, not part of where anything lives.
126
- path: path.replace(/^\.\.\//, ""),
127
- // A SOURCE, not a catalog: the two stamps are optional in a file, and
128
- // the estate's authored facts carry neither.
129
- catalog: catalog as SourceCatalog,
130
- }))
131
- .filter((source) => profileIncludesSource(activeCatalogProfile, source.path));
154
+ const imported = key.replace(/^\.\.\//, "");
155
+ // A SOURCE, not a catalog: a file carries no stamp of its own, and the
156
+ // history's travels beside it (portolan.0010).
157
+ const { source, ...stamp } = provenance[imported] ?? {};
158
+ return {
159
+ imported,
160
+ // A staged site imports a source under a flattened name; the file is
161
+ // still the one in the workspace, and that is the path a project's
162
+ // root is a prefix of and a link to the file names.
163
+ path: source ?? imported,
164
+ catalog: catalog as SourceCatalog,
165
+ stamp: "commit" in stamp ? stamp : undefined,
166
+ };
167
+ })
168
+ // The profile spells its sources the way the site imports them.
169
+ .filter((source) => profileIncludesSource(activeCatalogProfile, source.imported))
170
+ .map(({ imported: _imported, ...source }) => source);
132
171
 
133
172
  const merged = mergeCatalogs(sources);
134
173
  // Enriched before it is validated: the edges the flows imply are part of
@@ -11,10 +11,14 @@ import type {
11
11
  Service,
12
12
  Status,
13
13
  } from "./catalog";
14
- import { validateCatalog, walkSteps } from "./catalog";
14
+ import { buildIndex, validateCatalog, walkSteps } from "./catalog";
15
15
  import { enrichCatalog } from "./enrich";
16
+ import { contextMap } from "./lib/context-map";
16
17
  import { mergeCatalogs } from "./merge";
17
- import { problems } from "./lib/derive";
18
+ import { builtinProblems } from "./lib/problem-rules";
19
+
20
+ // The unresolved edges, as the two shipped rules about them find them.
21
+ const problems = (of: Catalog) => builtinProblems(of, buildIndex(of), ["rpc", "consumer"]);
18
22
 
19
23
  // ---------------------------------------------------------------------------
20
24
  // A tiny estate: two services, one event, one method, and whatever flow the
@@ -179,6 +183,420 @@ function serviceOf(catalog: Catalog, id: string): Service {
179
183
 
180
184
  // ---------------------------------------------------------------------------
181
185
 
186
+ describe("enrichCatalog: HTTP route correlation", () => {
187
+ function httpProvider(
188
+ slug: string,
189
+ operation: string,
190
+ path: string,
191
+ ): Service {
192
+ return service("shop", slug, {
193
+ provides: [
194
+ {
195
+ id: "api",
196
+ source: `${slug}/openapi.yaml`,
197
+ methods: [
198
+ {
199
+ name: operation,
200
+ doc: "",
201
+ request: "",
202
+ response: "",
203
+ http: { method: "POST", path },
204
+ },
205
+ ],
206
+ },
207
+ ],
208
+ });
209
+ }
210
+
211
+ function httpCaller(
212
+ path: string,
213
+ provides: Service["provides"] = [],
214
+ ): Service {
215
+ return service("shop", "oms", {
216
+ provides,
217
+ consumes: [
218
+ {
219
+ id: `http-client/POST ${path}`,
220
+ peer: "http-peer",
221
+ status: "unresolved",
222
+ source: "client.go:10",
223
+ },
224
+ ],
225
+ });
226
+ }
227
+
228
+ it("maps an outbound route to the unique other service that provides it", () => {
229
+ const callerProvides = httpProvider("oms", "POST /book", "/book").provides;
230
+ const caller = httpCaller("/book", callerProvides);
231
+ const supp = httpProvider("aviasupp", "POST /book", "/book");
232
+ const outbound = flow("book", [
233
+ step("shop.oms", "http-peer", "rpc", {
234
+ ref: "http-client/POST /book",
235
+ label: "POST /book",
236
+ status: "unresolved",
237
+ }),
238
+ ]);
239
+
240
+ const once = enrichCatalog(estate([outbound], [caller, supp])).catalog;
241
+ expect(serviceOf(once, "shop.oms").consumes).toEqual([
242
+ expect.objectContaining({
243
+ id: "api/POST /book",
244
+ peer: "shop.aviasupp",
245
+ status: "declared",
246
+ }),
247
+ ]);
248
+ expect(walkSteps(once.flows[0]!.steps)[0]).toEqual(
249
+ expect.objectContaining({
250
+ ref: "api/POST /book",
251
+ to: "shop.aviasupp",
252
+ status: "declared",
253
+ }),
254
+ );
255
+ expect(once.flows[0]!.participants).toContainEqual({
256
+ id: "shop.aviasupp",
257
+ kind: "service",
258
+ context: "shop",
259
+ });
260
+ expect(enrichCatalog(once).catalog).toEqual(once);
261
+ });
262
+
263
+ it("never confirms a link against a mounted route whose verb is unknown", () => {
264
+ // extract-django keeps a mounted `Planet.fetch` in `provides` with an
265
+ // empty method when no declaration proves the verb; the path alone is a
266
+ // candidate to look at, not a fact to link on.
267
+ const caller = httpCaller("/geo/planet/fetch");
268
+ const geo = service("shop", "geo", {
269
+ provides: [
270
+ {
271
+ id: "shop.geo.geo",
272
+ source: "geo/portolan/openapi.inferred.yaml",
273
+ methods: [
274
+ {
275
+ name: "geo_planet_fetch",
276
+ doc: "",
277
+ request: "",
278
+ response: "",
279
+ http: { method: "", path: "/geo/planet/fetch" },
280
+ },
281
+ ],
282
+ },
283
+ ],
284
+ });
285
+
286
+ const once = enrichCatalog(estate([], [caller, geo])).catalog;
287
+ expect(serviceOf(once, "shop.oms").consumes).toEqual([
288
+ expect.objectContaining({
289
+ id: "http-client/POST /geo/planet/fetch",
290
+ status: "unresolved",
291
+ }),
292
+ ]);
293
+ });
294
+
295
+ it("joins HTTP integrations contributed by independently added projects", () => {
296
+ const caller = service("aviacore", "aviacore", {
297
+ consumes: [
298
+ {
299
+ id: "http-client/POST /book",
300
+ peer: "http-peer",
301
+ status: "unresolved",
302
+ source: "internal/supplier/client.go:42",
303
+ },
304
+ ],
305
+ });
306
+ const provider = service("aviasupp", "aviasupp", {
307
+ provides: [
308
+ {
309
+ id: "api",
310
+ source: "docs/openapi.yaml",
311
+ methods: [
312
+ {
313
+ name: "book_post",
314
+ doc: "",
315
+ request: "",
316
+ response: "",
317
+ http: { method: "POST", path: "/book" },
318
+ },
319
+ ],
320
+ },
321
+ ],
322
+ });
323
+ const fragment = (id: string, owned: Service): Catalog => ({
324
+ generatedAt: "2026-01-01T00:00:00Z",
325
+ commit: id,
326
+ contexts: [context(id, [owned])],
327
+ defs: {},
328
+ flows: [],
329
+ adrs: [],
330
+ });
331
+ const merged = mergeCatalogs([
332
+ {
333
+ path: "vendor/repos/avia/aviacore/portolan/http-clients.json",
334
+ catalog: fragment("aviacore", caller),
335
+ },
336
+ {
337
+ path: "vendor/repos/avia/aviasupp/portolan/api.json",
338
+ catalog: fragment("aviasupp", provider),
339
+ },
340
+ ]).catalog;
341
+
342
+ const resolved = enrichCatalog(merged).catalog;
343
+ const call = serviceOf(resolved, "aviacore.aviacore").consumes[0];
344
+ expect(call).toMatchObject({
345
+ id: "api/book_post",
346
+ peer: "aviasupp.aviasupp",
347
+ status: "declared",
348
+ source: "internal/supplier/client.go:42",
349
+ });
350
+ expect(
351
+ contextMap(resolved).find(
352
+ (relation) => relation.id === "aviacore~aviasupp",
353
+ ),
354
+ ).toMatchObject({
355
+ dependencies: [
356
+ {
357
+ upstream: "aviasupp",
358
+ downstream: "aviacore",
359
+ links: [
360
+ {
361
+ id: "api/book_post",
362
+ from: "aviasupp.aviasupp",
363
+ to: "aviacore.aviacore",
364
+ status: "declared",
365
+ },
366
+ ],
367
+ },
368
+ ],
369
+ });
370
+ expect(() => validateCatalog(resolved)).not.toThrow();
371
+ });
372
+
373
+ it("maps a unique mounted route by its complete segment suffix", () => {
374
+ const caller = httpCaller("/get-admin-settings");
375
+ const admin = httpProvider(
376
+ "aviaadmin",
377
+ "settings_get_admin_settings_post",
378
+ "/settings/get-admin-settings",
379
+ );
380
+ const unrelatedParameterRoute = httpProvider(
381
+ "files",
382
+ "fileByID",
383
+ "/files/{id}",
384
+ );
385
+
386
+ const result = enrichCatalog(
387
+ estate([], [caller, admin, unrelatedParameterRoute]),
388
+ ).catalog;
389
+ expect(serviceOf(result, "shop.oms").consumes[0]).toEqual(
390
+ expect.objectContaining({
391
+ id: "api/settings_get_admin_settings_post",
392
+ peer: "shop.aviaadmin",
393
+ status: "declared",
394
+ }),
395
+ );
396
+ });
397
+
398
+ it("uses a proven full path even when another project has the same suffix", () => {
399
+ const caller = httpCaller("/get-admin-settings");
400
+ caller.consumes[0]!.destination = {
401
+ callSite: "client.go:10", endpointExpression: "c.baseURL + path", method: "POST",
402
+ localPath: "/get-admin-settings", fullPath: "/settings/get-admin-settings",
403
+ baseURL: { expression: "cfg.SettingAddr", configField: "Config.SettingAddr", environmentVariable: "SETTINGS_ADDR", kind: "config-default", value: "http://localhost:8000/settings", source: "config.go:84" },
404
+ join: { expression: "c.baseURL + path", source: "client.go:10" },
405
+ };
406
+ const admin = httpProvider("aviaadmin", "settings", "/settings/get-admin-settings");
407
+ const other = httpProvider("other", "settings", "/other/get-admin-settings");
408
+ const result = enrichCatalog(estate([], [caller, admin, other])).catalog;
409
+ 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" } } });
410
+ const missing = enrichCatalog(estate([], [caller, other])).catalog;
411
+ expect(serviceOf(missing, "shop.oms").consumes[0]!.status).toBe("unresolved");
412
+ expect(enrichCatalog(result).catalog).toEqual(result);
413
+ });
414
+
415
+ it("leaves ambiguous routes and possible self-calls unresolved", () => {
416
+ const caller = httpCaller("/book");
417
+ const first = httpProvider("first", "createBook", "/book");
418
+ const second = httpProvider("second", "book", "/book");
419
+
420
+ const ambiguous = enrichCatalog(
421
+ estate([], [caller, first, second]),
422
+ ).catalog;
423
+ expect(serviceOf(ambiguous, "shop.oms").consumes[0]).toEqual(
424
+ caller.consumes[0],
425
+ );
426
+
427
+ const selfOnly = enrichCatalog(
428
+ estate(
429
+ [],
430
+ [httpCaller("/book", httpProvider("oms", "book", "/book").provides)],
431
+ ),
432
+ ).catalog;
433
+ expect(serviceOf(selfOnly, "shop.oms").consumes[0]).toEqual(
434
+ caller.consumes[0],
435
+ );
436
+
437
+ const rootWithoutRootProvider = enrichCatalog(
438
+ estate([], [httpCaller("/"), first]),
439
+ ).catalog;
440
+ expect(serviceOf(rootWithoutRootProvider, "shop.oms").consumes[0]).toEqual(
441
+ httpCaller("/").consumes[0],
442
+ );
443
+ });
444
+
445
+ // PORTOLAN-19: the extractor qualifies a raw call with its destination, so
446
+ // the same route addressed to two hosts arrives as two consumes. They must
447
+ // stay two - resolved to two providers when the estate has them, or two
448
+ // unresolved calls when it does not - never one glued entry.
449
+ it("keeps the same route to two hosts as two calls", () => {
450
+ const toHost = (host: string): Service["consumes"][number] => ({
451
+ id: `http-client/POST /foo @ ${host}`,
452
+ peer: host.replace(".", "-"),
453
+ status: "unresolved",
454
+ source: "client.go:11",
455
+ destination: {
456
+ callSite: "client.go:11",
457
+ endpointExpression: "path",
458
+ method: "POST",
459
+ localPath: "/foo",
460
+ fullPath: "/foo",
461
+ serviceDiscoveryAlias: host,
462
+ },
463
+ });
464
+ const caller = service("shop", "oms", {
465
+ consumes: [toHost("payments.internal"), toHost("ledger.internal")],
466
+ });
467
+ const payments = httpProvider("payments", "POST /foo", "/foo");
468
+ const ledger = httpProvider("ledger", "POST /foo", "/foo");
469
+
470
+ const ambiguous = enrichCatalog(
471
+ estate([], [caller, payments, ledger]),
472
+ ).catalog;
473
+ expect(
474
+ serviceOf(ambiguous, "shop.oms").consumes.map((call) => [
475
+ call.id,
476
+ call.peer,
477
+ call.status,
478
+ ]),
479
+ ).toEqual([
480
+ ["http-client/POST /foo @ payments.internal", "payments-internal", "unresolved"],
481
+ ["http-client/POST /foo @ ledger.internal", "ledger-internal", "unresolved"],
482
+ ]);
483
+ });
484
+
485
+ // The same two providers, and now the manifests say which name each one
486
+ // answers on. The host the call names decides between them; a host nobody
487
+ // answers on decides nothing.
488
+ it("lets the manifests decide between two providers of one route", () => {
489
+ const toHost = (host: string): Service["consumes"][number] => ({
490
+ id: `http-client/POST /foo @ ${host}`,
491
+ peer: host.replace(".", "-"),
492
+ status: "unresolved",
493
+ source: "client.go:11",
494
+ destination: {
495
+ callSite: "client.go:11",
496
+ endpointExpression: "path",
497
+ method: "POST",
498
+ localPath: "/foo",
499
+ fullPath: "/foo",
500
+ serviceDiscoveryAlias: host,
501
+ },
502
+ });
503
+ const caller = service("shop", "oms", {
504
+ consumes: [
505
+ toHost("payments.internal"),
506
+ toHost("ledger.internal"),
507
+ toHost("nobody.internal"),
508
+ ],
509
+ });
510
+ const payments = {
511
+ ...httpProvider("payments", "pay", "/foo"),
512
+ hosts: ["payments", "payments.internal"],
513
+ };
514
+ const ledger = {
515
+ ...httpProvider("ledger", "post", "/foo"),
516
+ hosts: ["ledger.internal"],
517
+ };
518
+
519
+ const out = enrichCatalog(estate([], [caller, payments, ledger])).catalog;
520
+ expect(
521
+ serviceOf(out, "shop.oms").consumes.map((call) => [
522
+ call.id,
523
+ call.peer,
524
+ call.status,
525
+ call.destination?.resolution?.basis,
526
+ ]),
527
+ ).toEqual([
528
+ ["api/pay", "shop.payments", "declared", "kubernetes-host"],
529
+ ["api/post", "shop.ledger", "declared", "kubernetes-host"],
530
+ [
531
+ "http-client/POST /foo @ nobody.internal",
532
+ "nobody-internal",
533
+ "unresolved",
534
+ undefined,
535
+ ],
536
+ ]);
537
+ });
538
+
539
+ it("uses what the caller dials when the call names no host", () => {
540
+ const caller = service("shop", "oms", {
541
+ consumes: [
542
+ {
543
+ id: "http-client/POST /foo",
544
+ peer: "http-peer",
545
+ status: "unresolved",
546
+ source: "client.go:10",
547
+ },
548
+ ],
549
+ dials: ["ledger.payments.svc"],
550
+ });
551
+ const payments = {
552
+ ...httpProvider("payments", "pay", "/foo"),
553
+ hosts: ["payments.shop.svc"],
554
+ };
555
+ const ledger = {
556
+ ...httpProvider("ledger", "post", "/foo"),
557
+ hosts: ["ledger.payments.svc"],
558
+ };
559
+
560
+ const out = enrichCatalog(estate([], [caller, payments, ledger])).catalog;
561
+ expect(
562
+ serviceOf(out, "shop.oms").consumes.map((call) => [
563
+ call.id,
564
+ call.peer,
565
+ call.status,
566
+ call.destination?.resolution?.basis,
567
+ ]),
568
+ ).toEqual([["api/post", "shop.ledger", "declared", "kubernetes-host"]]);
569
+ });
570
+
571
+ it("resolves a destination-qualified route like a bare one", () => {
572
+ const caller = service("shop", "oms", {
573
+ consumes: [
574
+ {
575
+ id: "http-client/POST /book @ Config.SupplierURL",
576
+ peer: "http-peer",
577
+ status: "unresolved",
578
+ source: "client.go:10",
579
+ },
580
+ ],
581
+ });
582
+ const supp = httpProvider("aviasupp", "POST /book", "/book");
583
+ const once = enrichCatalog(estate([], [caller, supp])).catalog;
584
+ expect(serviceOf(once, "shop.oms").consumes).toEqual([
585
+ expect.objectContaining({
586
+ id: "api/POST /book",
587
+ peer: "shop.aviasupp",
588
+ status: "declared",
589
+ destination: expect.objectContaining({
590
+ resolution: { basis: "exact-route", provider: "shop.aviasupp", route: "/book" },
591
+ }),
592
+ }),
593
+ ]);
594
+ expect(enrichCatalog(once).catalog).toEqual(once);
595
+ });
596
+ });
597
+
598
+ // ---------------------------------------------------------------------------
599
+
182
600
  describe("enrichCatalog: asynchronous outbound continuations", () => {
183
601
  function outbound(slug: string, entrypoint: string, path: string): Flow {
184
602
  return {
@@ -709,7 +1127,7 @@ describe("enrichCatalog: consumers from event steps", () => {
709
1127
  ["risk", "unresolved"],
710
1128
  ["ghost.svc", "unresolved"],
711
1129
  ]);
712
- expect(problems(catalog).map((p) => [p.kind, p.peer])).toEqual([
1130
+ expect(problems(catalog).map((p) => [p.rule, p.peer])).toEqual([
713
1131
  ["consumer", "risk"],
714
1132
  ["consumer", "ghost.svc"],
715
1133
  ]);
@@ -1100,10 +1518,12 @@ describe("enrichCatalog: Redis store accesses", () => {
1100
1518
 
1101
1519
  describe("enrichCatalog: the auth fragment", () => {
1102
1520
  // The three fragments together, the way the app reads them: the domain one
1103
- // alone names endpoints the api one declares.
1521
+ // alone names endpoints the api one declares. A fragment carries no stamp
1522
+ // of its own (portolan.0010); the reader hands the history's over beside it.
1104
1523
  const raw = mergeCatalogs(
1105
1524
  ["domain", "api", "stores"].map((name) => ({
1106
1525
  path: `${name}.json`,
1526
+ stamp: { commit: "abc1234", generatedAt: "2026-09-05T00:00:00Z" },
1107
1527
  catalog: JSON.parse(
1108
1528
  readFileSync(
1109
1529
  new URL(`../examples/auth/portolan/${name}.json`, import.meta.url),
@@ -1346,6 +1766,41 @@ describe("enrichCatalog: a foreign key into another service's table", () => {
1346
1766
  expect(keyOf(enrichCatalog(catalog).catalog)).toBe("orders");
1347
1767
  });
1348
1768
 
1769
+ it("tells two tables of one name apart by who the referencing service talks to", () => {
1770
+ // The delivery service calls the order service; an unrelated shop in
1771
+ // the same estate has an `orders` table too.
1772
+ const catalog = estateWithStores();
1773
+ catalog.contexts.push(
1774
+ context("delivery", [
1775
+ service("delivery", "core", {
1776
+ consumes: [{ id: "oms.v1.Orders/GetOrder", peer: "shop.oms", status: "declared", source: "x" }],
1777
+ }),
1778
+ ]),
1779
+ context("commerce", [service("commerce", "bagisto")]),
1780
+ );
1781
+ catalog.stores!.push({
1782
+ id: "commerce.bagisto.db",
1783
+ slug: "db",
1784
+ name: "Bagisto database",
1785
+ kind: "mysql",
1786
+ owner: "commerce.bagisto",
1787
+ tables: [{ id: "commerce.bagisto.db.orders", name: "orders", columns: [] }],
1788
+ });
1789
+
1790
+ expect(keyOf(enrichCatalog(catalog).catalog)).toBe("shop.oms.pg.orders");
1791
+ });
1792
+
1793
+ it("prefers a table of the store's own before looking across the estate", () => {
1794
+ const catalog = estateWithStores();
1795
+ catalog.stores![0]!.tables.push({
1796
+ id: "delivery.core.pg.orders",
1797
+ name: "orders",
1798
+ columns: [{ name: "id", type: "text", nullable: false, pk: true }],
1799
+ });
1800
+
1801
+ expect(keyOf(enrichCatalog(catalog).catalog)).toBe("delivery.core.pg.orders");
1802
+ });
1803
+
1349
1804
  it("leaves a key that already resolves untouched, and enriching twice changes nothing", () => {
1350
1805
  const once = enrichCatalog(estateWithStores()).catalog;
1351
1806
  const twice = enrichCatalog(once).catalog;