@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
@@ -0,0 +1,740 @@
1
+ package extracthttpclients
2
+
3
+ import (
4
+ "encoding/json"
5
+ "net/url"
6
+ "path/filepath"
7
+ "sort"
8
+ "strconv"
9
+ "strings"
10
+ "unicode"
11
+
12
+ "github.com/shortlink-org/portolan/catalog"
13
+ "github.com/shortlink-org/portolan/internal/gohttp"
14
+ "github.com/shortlink-org/portolan/plugin"
15
+ )
16
+
17
+ func extract(in plugin.Input, opts Options) (plugin.Response, error) {
18
+ result, err := gohttp.Analyze(in.Root)
19
+ if err != nil {
20
+ return plugin.Response{}, err
21
+ }
22
+ b := &plugin.Builder{}
23
+ for _, warning := range result.Warnings {
24
+ b.Warn(in.Root, warning)
25
+ }
26
+ if result.TypedCallGraphError != "" {
27
+ b.Warn(in.Root, "typed call graph unavailable; using syntax fallback: "+result.TypedCallGraphError)
28
+ }
29
+
30
+ serviceID := opts.Context + "." + opts.Service
31
+ externals := externalCatalog(result.Contracts, result.Calls, opts)
32
+ // One consumer entry per call identity. A raw call's ID already carries its
33
+ // destination (see gohttp.withCallID), so two `POST /foo` against different
34
+ // hosts land as two entries; a generated client's ID is its contract
35
+ // operation, whose peer is known. Several call sites that share an identity
36
+ // are one dependency: the entry keeps the first site in source order (the
37
+ // analyzer sorts by file and line, so traversal order cannot change it) and
38
+ // carries every site as evidence.
39
+ consumes := make([]catalog.RpcCall, 0, len(result.Calls))
40
+ consumeAt := map[string]int{}
41
+ for _, call := range result.Calls {
42
+ peer, status := peerOf(call, opts)
43
+ if at, seen := consumeAt[call.ID]; seen {
44
+ consumes[at].Evidence = appendEvidence(consumes[at].Evidence, httpCallEvidence(call))
45
+ continue
46
+ }
47
+ consumeAt[call.ID] = len(consumes)
48
+ source := call.Source.String()
49
+ if call.Contract != "" {
50
+ source = call.Contract
51
+ }
52
+ consumes = append(consumes, catalog.RpcCall{
53
+ ID: call.ID, Peer: peer, Status: status, Source: source,
54
+ Note: callNote(call), Destination: call.Destination, Evidence: httpCallEvidence(call),
55
+ })
56
+ }
57
+ sort.Slice(consumes, func(i, j int) bool { return consumes[i].ID < consumes[j].ID })
58
+
59
+ endpointFlows, covered := flowsOfEndpoints(serviceID, opts.Context, result.EndpointFlows, opts)
60
+ coverEndpointDescendants(covered, result.EndpointFlows, result.Flows)
61
+ rootFlows := flowsOfRoots(serviceID, opts.Context, result.RootFlows, opts, covered)
62
+ coverRootDescendants(covered, result.RootFlows, result.Flows)
63
+ flows := append(endpointFlows, rootFlows...)
64
+ flows = append(flows, flowsOfGroupsExcept(serviceID, opts.Context, result.Flows, opts, covered)...)
65
+ if len(result.Calls) == 0 {
66
+ b.Warn(in.Root, "no outbound net/http, oapi-codegen, or SOAP calls were found")
67
+ }
68
+ fragment := catalog.Catalog{
69
+ Contexts: []catalog.BoundedContext{{
70
+ ID: opts.Context, Slug: opts.Context,
71
+ Services: []catalog.Service{{
72
+ ID: serviceID, Slug: opts.Service,
73
+ Provides: []catalog.RpcService{}, Consumes: consumes, Aggregates: []catalog.Aggregate{},
74
+ }},
75
+ }},
76
+ Defs: map[string]catalog.TypeDef{}, Flows: flows, Adrs: []catalog.Adr{}, Externals: externals,
77
+ }
78
+ encoded, err := json.MarshalIndent(fragment, "", " ")
79
+ if err != nil {
80
+ return plugin.Response{}, err
81
+ }
82
+ b.File(firstNonEmpty(opts.Out, "http-clients.json"), string(encoded)+"\n")
83
+ return b.Response(), nil
84
+ }
85
+
86
+ func externalCatalog(contracts []gohttp.Contract, calls []gohttp.Call, opts Options) []catalog.External {
87
+ used := map[string]bool{}
88
+ called := map[string]bool{}
89
+ for _, call := range calls {
90
+ used[call.API] = call.API != ""
91
+ called[call.ID] = true
92
+ }
93
+ byExternal := map[string]*catalog.External{}
94
+ for _, contract := range contracts {
95
+ if !used[contract.API] || opts.Peers[contract.API] != "" {
96
+ continue
97
+ }
98
+ external := firstNonEmpty(opts.Externals[contract.API], contract.External)
99
+ if external == "" {
100
+ continue
101
+ }
102
+ target := byExternal[external]
103
+ if target == nil {
104
+ target = &catalog.External{
105
+ ID: external, Slug: external, Name: firstNonEmpty(contract.Name, title(external)),
106
+ Summary: contract.Summary, URL: contract.URL, Provides: []catalog.RpcService{},
107
+ }
108
+ byExternal[external] = target
109
+ }
110
+ byInterface := map[string][]catalog.RpcMethod{}
111
+ for _, op := range contract.Operations {
112
+ if !called[op.CallID(contract.API)] {
113
+ continue
114
+ }
115
+ iface := op.Interface(contract.API)
116
+ byInterface[iface] = append(byInterface[iface], catalog.RpcMethod{
117
+ Name: op.ID, HTTP: &catalog.HttpRoute{Method: op.Verb, Path: op.Path},
118
+ })
119
+ }
120
+ for _, op := range contract.SOAP {
121
+ if !called[op.Interface+"/"+op.ID] {
122
+ continue
123
+ }
124
+ byInterface[op.Interface] = append(byInterface[op.Interface], catalog.RpcMethod{
125
+ Name: op.ID, Request: op.Request, Response: op.Response,
126
+ SOAP: &catalog.SoapRoute{
127
+ Action: op.Action, Version: op.Version, Style: op.Style,
128
+ Endpoint: op.Endpoint, Binding: op.Binding,
129
+ Faults: append([]string(nil), op.Faults...), Headers: append([]string(nil), op.Headers...),
130
+ },
131
+ })
132
+ }
133
+ var provides []catalog.RpcService
134
+ for id, methods := range byInterface {
135
+ sort.Slice(methods, func(i, j int) bool { return methods[i].Name < methods[j].Name })
136
+ provides = append(provides, catalog.RpcService{ID: id, Source: contract.Source, Methods: methods})
137
+ }
138
+ sort.Slice(provides, func(i, j int) bool { return provides[i].ID < provides[j].ID })
139
+ for _, provided := range provides {
140
+ at := -1
141
+ for i := range target.Provides {
142
+ if target.Provides[i].ID == provided.ID {
143
+ at = i
144
+ break
145
+ }
146
+ }
147
+ if at < 0 {
148
+ target.Provides = append(target.Provides, provided)
149
+ continue
150
+ }
151
+ seenMethod := map[string]bool{}
152
+ for _, method := range target.Provides[at].Methods {
153
+ seenMethod[method.Name] = true
154
+ }
155
+ for _, method := range provided.Methods {
156
+ if !seenMethod[method.Name] {
157
+ target.Provides[at].Methods = append(target.Provides[at].Methods, method)
158
+ seenMethod[method.Name] = true
159
+ }
160
+ }
161
+ sort.Slice(target.Provides[at].Methods, func(i, j int) bool {
162
+ return target.Provides[at].Methods[i].Name < target.Provides[at].Methods[j].Name
163
+ })
164
+ }
165
+ }
166
+ var out []catalog.External
167
+ for _, external := range byExternal {
168
+ sort.Slice(external.Provides, func(i, j int) bool { return external.Provides[i].ID < external.Provides[j].ID })
169
+ out = append(out, *external)
170
+ }
171
+ sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
172
+ return out
173
+ }
174
+
175
+ func flowsOf(serviceID, context string, calls []gohttp.Call, opts Options) []catalog.Flow {
176
+ byFunction := map[string][]gohttp.Call{}
177
+ for _, call := range calls {
178
+ byFunction[call.Function] = append(byFunction[call.Function], call)
179
+ }
180
+ functions := make([]string, 0, len(byFunction))
181
+ for function := range byFunction {
182
+ functions = append(functions, function)
183
+ }
184
+ sort.Strings(functions)
185
+ groups := make([]gohttp.FlowGroup, 0, len(functions))
186
+ for _, function := range functions {
187
+ groups = append(groups, gohttp.FlowGroup{Function: function, Calls: byFunction[function]})
188
+ }
189
+ return flowsOfGroups(serviceID, context, groups, opts)
190
+ }
191
+
192
+ func flowsOfGroups(serviceID, context string, groups []gohttp.FlowGroup, opts Options) []catalog.Flow {
193
+ return flowsOfGroupsExcept(serviceID, context, groups, opts, nil)
194
+ }
195
+
196
+ func flowsOfGroupsExcept(serviceID, context string, groups []gohttp.FlowGroup, opts Options, excluded map[string]bool) []catalog.Flow {
197
+ var flows []catalog.Flow
198
+ for _, flowGroup := range groups {
199
+ if excluded[flowGroup.Function] {
200
+ continue
201
+ }
202
+ function := flowGroup.Function
203
+ group := flowGroup.Calls
204
+ if len(group) == 0 {
205
+ continue
206
+ }
207
+ participants := []catalog.Participant{{ID: serviceID, Kind: catalog.ParticipantService, Context: &context}}
208
+ participantSeen := map[string]bool{serviceID: true}
209
+ callSteps := make([]*catalog.Step, 0, len(group))
210
+ for index, call := range group {
211
+ peer, status := peerOf(call, opts)
212
+ participant := participantOf(peer, call, opts)
213
+ if !participantSeen[participant.ID] {
214
+ participants = append(participants, participant)
215
+ participantSeen[participant.ID] = true
216
+ }
217
+ note := callNote(call)
218
+ if len(call.Conditions) > 0 {
219
+ condition := "when " + strings.Join(uniqueStrings(call.Conditions), " and ")
220
+ if note == "" {
221
+ note = condition
222
+ } else {
223
+ note += "; " + condition
224
+ }
225
+ }
226
+ if len(call.Chain) > 1 {
227
+ chain := "via " + strings.Join(call.Chain, " → ")
228
+ if note == "" {
229
+ note = chain
230
+ } else {
231
+ note += "; " + chain
232
+ }
233
+ }
234
+ callSteps = append(callSteps, &catalog.Step{
235
+ Type: "step", ID: "s" + strconv.Itoa(index+1), From: serviceID, To: participant.ID,
236
+ Kind: catalog.StepRPC, Ref: call.ID, Label: callLabel(call), Status: status,
237
+ Note: note, Line: call.Source.String(), Destination: call.Destination, Evidence: httpCallEvidence(call),
238
+ })
239
+ }
240
+ steps := catalog.FlowNodes{}
241
+ if left, right, ok := complementaryCalls(group); ok && len(group) == 2 {
242
+ steps = append(steps, &catalog.Alt{
243
+ Type: "alt", ID: "alt1",
244
+ Branches: []catalog.AltBranch{
245
+ {Title: left, Steps: catalog.FlowNodes{callSteps[0]}},
246
+ {Title: right, Steps: catalog.FlowNodes{callSteps[1]}},
247
+ },
248
+ })
249
+ } else {
250
+ for _, step := range callSteps {
251
+ steps = append(steps, step)
252
+ }
253
+ }
254
+ name := flowName(function)
255
+ flowSource := flowGroup.Source.String()
256
+ if flowSource == "" {
257
+ flowSource = group[0].Source.String()
258
+ }
259
+ flows = append(flows, catalog.Flow{
260
+ ID: "flow." + serviceID + ".http-client." + slug(function),
261
+ Slug: serviceID + "-http-client-" + slug(function),
262
+ Name: name + " → outbound APIs",
263
+ Summary: standaloneFlowSummary(function, flowGroup.Callers),
264
+ Source: flowSource,
265
+ Trigger: &catalog.FlowTrigger{
266
+ Kind: "unproven", Label: "No execution root proven", Confidence: "low",
267
+ },
268
+ EntryPoint: function, Owner: context,
269
+ Participants: participants, Steps: steps,
270
+ })
271
+ }
272
+ return flows
273
+ }
274
+
275
+ func flowsOfRoots(serviceID, context string, roots []gohttp.RootFlow, opts Options, covered map[string]bool) []catalog.Flow {
276
+ flows := make([]catalog.Flow, 0, len(roots))
277
+ for _, root := range roots {
278
+ for _, function := range root.Covered {
279
+ covered[function] = true
280
+ }
281
+ actorID := serviceID + ".api-client"
282
+ actorLabel := "API client"
283
+ firstKind := catalog.StepRPC
284
+ firstLabel := strings.TrimSpace(root.Method + " " + root.Path)
285
+ if root.Kind == gohttp.RootCallback {
286
+ actorID = serviceID + ".callback-sender"
287
+ actorLabel = "Callback sender"
288
+ }
289
+ if root.Kind == gohttp.RootStartup {
290
+ actorID = serviceID + ".process"
291
+ actorLabel = "Process startup"
292
+ firstKind = catalog.StepCall
293
+ firstLabel = root.Label
294
+ }
295
+ if root.Kind == gohttp.RootScheduled {
296
+ actorID = serviceID + ".scheduler"
297
+ actorLabel = "Scheduler"
298
+ firstKind = catalog.StepCall
299
+ firstLabel = root.Label
300
+ }
301
+ participants := []catalog.Participant{
302
+ {ID: actorID, Kind: catalog.ParticipantActor, Label: actorLabel},
303
+ {ID: serviceID, Kind: catalog.ParticipantService, Context: &context},
304
+ }
305
+ participantSeen := map[string]bool{actorID: true, serviceID: true}
306
+ steps := catalog.FlowNodes{&catalog.Step{
307
+ Type: "step", ID: "s1", From: actorID, To: serviceID,
308
+ Kind: firstKind, Label: firstLabel, Status: catalog.StatusDeclared,
309
+ Line: root.Source.String(),
310
+ }}
311
+ for index, call := range root.Calls {
312
+ peer, status := peerOf(call, opts)
313
+ participant := participantOf(peer, call, opts)
314
+ if !participantSeen[participant.ID] {
315
+ participants = append(participants, participant)
316
+ participantSeen[participant.ID] = true
317
+ }
318
+ note := callNote(call)
319
+ if len(call.Conditions) > 0 {
320
+ condition := "when " + strings.Join(uniqueStrings(call.Conditions), " and ")
321
+ if note == "" {
322
+ note = condition
323
+ } else {
324
+ note += "; " + condition
325
+ }
326
+ }
327
+ if len(call.Chain) > 1 {
328
+ chain := "via " + strings.Join(call.Chain, " → ")
329
+ if note == "" {
330
+ note = chain
331
+ } else {
332
+ note += "; " + chain
333
+ }
334
+ }
335
+ steps = append(steps, &catalog.Step{
336
+ Type: "step", ID: "s" + strconv.Itoa(index+2), From: serviceID, To: participant.ID,
337
+ Kind: catalog.StepRPC, Ref: call.ID, Label: callLabel(call), Status: status,
338
+ Note: note, Line: call.Source.String(), Destination: call.Destination, Evidence: httpCallEvidence(call),
339
+ })
340
+ }
341
+ reached := append([]string{}, root.Covered...)
342
+ if root.Handler != "" {
343
+ reached = append(reached, root.Handler)
344
+ }
345
+ markLastStepReaches(steps, reached)
346
+ summary := "Source-backed execution path from a concrete root to outbound APIs."
347
+ switch root.Kind {
348
+ case gohttp.RootCallback:
349
+ summary = "Source-backed callback path from the inbound webhook to outbound APIs."
350
+ case gohttp.RootStartup:
351
+ summary = "Source-backed startup path executed while the process is assembled."
352
+ case gohttp.RootScheduled:
353
+ summary = "Source-backed scheduled path from timer registration to outbound APIs."
354
+ case gohttp.RootHTTP:
355
+ summary = "Source-backed request path from the inbound endpoint to outbound APIs."
356
+ }
357
+ rootIdentity := root.Label
358
+ if root.Kind == gohttp.RootStartup {
359
+ rootIdentity = strings.TrimPrefix(rootIdentity, "Startup → ")
360
+ }
361
+ rootSlug := slug(string(root.Kind) + "-" + rootIdentity)
362
+ flows = append(flows, catalog.Flow{
363
+ ID: "flow." + serviceID + ".root." + rootSlug,
364
+ Slug: serviceID + "-root-" + rootSlug,
365
+ Name: root.Label + " → outbound APIs", Summary: summary,
366
+ Source: root.Source.String(),
367
+ Trigger: &catalog.FlowTrigger{
368
+ Kind: string(root.Kind), Label: root.Label, Confidence: root.Confidence,
369
+ },
370
+ Owner: context, Participants: participants, Steps: steps,
371
+ })
372
+ }
373
+ return flows
374
+ }
375
+
376
+ func standaloneFlowSummary(function string, callers []string) string {
377
+ base := "Source-backed outbound calls made by '" + function + "'."
378
+ if len(callers) == 0 {
379
+ return base + " No source caller was found; this may be a public entrypoint, a background task, or unreachable code."
380
+ }
381
+ displays := make([]string, 0, len(callers))
382
+ for _, caller := range callers {
383
+ displays = append(displays, flowName(caller))
384
+ }
385
+ return base + " Source callers were found (" + strings.Join(uniqueStrings(displays), ", ") + "), but no inbound or asynchronous root was proven."
386
+ }
387
+
388
+ func flowsOfEndpoints(serviceID, context string, endpoints []gohttp.EndpointFlow, opts Options) ([]catalog.Flow, map[string]bool) {
389
+ covered := map[string]bool{}
390
+ flows := make([]catalog.Flow, 0, len(endpoints))
391
+ for _, endpoint := range endpoints {
392
+ actorID := serviceID + ".api-client"
393
+ participants := []catalog.Participant{
394
+ {ID: actorID, Kind: catalog.ParticipantActor, Label: "API client"},
395
+ {ID: serviceID, Kind: catalog.ParticipantService, Context: &context},
396
+ }
397
+ participantSeen := map[string]bool{actorID: true, serviceID: true}
398
+ branches := make([]catalog.AltBranch, 0, len(endpoint.Branches))
399
+ stepIndex := 2
400
+ for _, branch := range endpoint.Branches {
401
+ covered[branch.Function] = true
402
+ steps := catalog.FlowNodes{}
403
+ for _, call := range branch.Calls {
404
+ peer, status := peerOf(call, opts)
405
+ participant := participantOf(peer, call, opts)
406
+ if !participantSeen[participant.ID] {
407
+ participants = append(participants, participant)
408
+ participantSeen[participant.ID] = true
409
+ }
410
+ note := callNote(call)
411
+ if len(call.Chain) > 1 {
412
+ chain := "via " + strings.Join(call.Chain, " → ")
413
+ if note == "" {
414
+ note = chain
415
+ } else {
416
+ note += "; " + chain
417
+ }
418
+ }
419
+ steps = append(steps, &catalog.Step{
420
+ Type: "step", ID: "s" + strconv.Itoa(stepIndex), From: serviceID, To: participant.ID,
421
+ Kind: catalog.StepRPC, Ref: call.ID, Label: callLabel(call), Status: status,
422
+ Note: note, Line: call.Source.String(), Destination: call.Destination, Evidence: httpCallEvidence(call),
423
+ })
424
+ stepIndex++
425
+ }
426
+ if len(steps) == 0 {
427
+ provider := filepath.Base(branch.Provider)
428
+ steps = append(steps, &catalog.Step{
429
+ Type: "step", ID: "s" + strconv.Itoa(stepIndex), From: serviceID, To: serviceID,
430
+ Kind: catalog.StepCall, Label: title(provider) + " " + title(branch.Operation),
431
+ Status: catalog.StatusDeclared,
432
+ Note: "provider implementation is source-backed; its outbound transport was not resolved in the analyzed source",
433
+ Line: branch.Source.String(),
434
+ })
435
+ stepIndex++
436
+ }
437
+ markLastStepReaches(steps, []string{branch.Function})
438
+ branches = append(branches, catalog.AltBranch{
439
+ Title: "connector = \"" + branch.Condition + "\"", Steps: steps,
440
+ })
441
+ }
442
+ steps := catalog.FlowNodes{&catalog.Step{
443
+ Type: "step", ID: "s1", From: actorID, To: serviceID,
444
+ Kind: catalog.StepRPC, Label: strings.TrimSpace(endpoint.Method + " " + endpoint.Path),
445
+ Status: catalog.StatusDeclared, Line: endpoint.Source.String(),
446
+ }}
447
+ if len(branches) == 1 {
448
+ steps = append(steps, branches[0].Steps...)
449
+ } else {
450
+ steps = append(steps, &catalog.Alt{Type: "alt", ID: "providers", Branches: branches})
451
+ }
452
+ name := strings.TrimSpace(endpoint.Method + " " + endpoint.Path)
453
+ flows = append(flows, catalog.Flow{
454
+ ID: "flow." + serviceID + ".endpoint." + slug(name),
455
+ Slug: serviceID + "-endpoint-" + slug(name),
456
+ Name: name + " → provider APIs",
457
+ Summary: "Source-backed request path from the inbound endpoint through provider selection to outbound APIs.",
458
+ Source: endpoint.Source.String(),
459
+ Trigger: &catalog.FlowTrigger{
460
+ Kind: "http", Label: name, Confidence: "high",
461
+ },
462
+ Owner: context, Participants: participants, Steps: steps,
463
+ })
464
+ }
465
+ return flows, covered
466
+ }
467
+
468
+ func markLastStepReaches(nodes catalog.FlowNodes, reaches []string) {
469
+ for index := len(nodes) - 1; index >= 0; index-- {
470
+ switch node := nodes[index].(type) {
471
+ case *catalog.Step:
472
+ node.Reaches = uniqueStrings(reaches)
473
+ return
474
+ case *catalog.Alt:
475
+ for branch := range node.Branches {
476
+ markLastStepReaches(node.Branches[branch].Steps, reaches)
477
+ }
478
+ return
479
+ case *catalog.Parallel:
480
+ for branch := range node.Branches {
481
+ markLastStepReaches(node.Branches[branch], reaches)
482
+ }
483
+ return
484
+ case *catalog.Loop:
485
+ markLastStepReaches(node.Steps, reaches)
486
+ return
487
+ }
488
+ }
489
+ }
490
+
491
+ func coverRootDescendants(covered map[string]bool, roots []gohttp.RootFlow, groups []gohttp.FlowGroup) {
492
+ for _, root := range roots {
493
+ callKeys := map[string]bool{}
494
+ for _, call := range root.Calls {
495
+ callKeys[endpointCallKey(call)] = true
496
+ }
497
+ for _, group := range groups {
498
+ if covered[group.Function] || len(group.Calls) == 0 {
499
+ continue
500
+ }
501
+ allCallsCovered := true
502
+ for _, call := range group.Calls {
503
+ if !callKeys[endpointCallKey(call)] {
504
+ allCallsCovered = false
505
+ break
506
+ }
507
+ }
508
+ if allCallsCovered {
509
+ covered[group.Function] = true
510
+ }
511
+ }
512
+ }
513
+ }
514
+
515
+ func coverEndpointDescendants(covered map[string]bool, endpoints []gohttp.EndpointFlow, groups []gohttp.FlowGroup) {
516
+ for _, endpoint := range endpoints {
517
+ // Typed dispatch can lift a union of calls from every provider branch
518
+ // into a shared coordinator such as ActionFlow. Compare those callers
519
+ // with the whole endpoint, not with one branch at a time, or the same
520
+ // source-backed calls reappear as a misleading standalone flow.
521
+ callKeys := map[string]bool{}
522
+ for _, branch := range endpoint.Branches {
523
+ for _, call := range branch.Calls {
524
+ callKeys[endpointCallKey(call)] = true
525
+ }
526
+ }
527
+ for _, group := range groups {
528
+ if covered[group.Function] || len(group.Calls) == 0 {
529
+ continue
530
+ }
531
+ // Exact source location plus operation id is stronger evidence than
532
+ // a display name in the reconstructed call chain. Wrappers reached
533
+ // through interfaces and promoted methods can omit that name while
534
+ // still contributing precisely the same source-backed calls.
535
+ allCallsCovered := true
536
+ for _, call := range group.Calls {
537
+ if !callKeys[endpointCallKey(call)] {
538
+ allCallsCovered = false
539
+ break
540
+ }
541
+ }
542
+ if allCallsCovered {
543
+ covered[group.Function] = true
544
+ }
545
+ }
546
+ }
547
+ }
548
+
549
+ func endpointCallKey(call gohttp.Call) string {
550
+ return call.Source.String() + "\x00" + call.ID
551
+ }
552
+
553
+ func flowName(function string) string {
554
+ directory, display, qualified := strings.Cut(function, ":")
555
+ if !qualified {
556
+ display = function
557
+ }
558
+ receiver, method, methodCall := strings.Cut(display, ".")
559
+ if !methodCall || (receiver != "Client" && receiver != "Connector") {
560
+ return title(strings.ReplaceAll(display, ".", " "))
561
+ }
562
+ parts := strings.Split(filepath.ToSlash(directory), "/")
563
+ for index := len(parts) - 1; index >= 0; index-- {
564
+ part := parts[index]
565
+ if part == "" || part == "client" || part == "connector" || part == "adapter" {
566
+ continue
567
+ }
568
+ return title(part) + " " + title(method)
569
+ }
570
+ return title(strings.ReplaceAll(display, ".", " "))
571
+ }
572
+
573
+ func complementaryCalls(calls []gohttp.Call) (string, string, bool) {
574
+ if len(calls) != 2 {
575
+ return "", "", false
576
+ }
577
+ for _, left := range calls[0].Conditions {
578
+ for _, right := range calls[1].Conditions {
579
+ if right == "not ("+left+")" || left == "not ("+right+")" {
580
+ return left, right, true
581
+ }
582
+ }
583
+ }
584
+ return "", "", false
585
+ }
586
+
587
+ func participantOf(peer string, call gohttp.Call, opts Options) catalog.Participant {
588
+ if call.API != "" {
589
+ if service := opts.Peers[call.API]; service != "" {
590
+ context, _, _ := strings.Cut(service, ".")
591
+ return catalog.Participant{ID: service, Kind: catalog.ParticipantService, Context: &context}
592
+ }
593
+ if external := firstNonEmpty(opts.Externals[call.API], call.External); external != "" {
594
+ return catalog.Participant{ID: external, Kind: catalog.ParticipantExternal}
595
+ }
596
+ }
597
+ return catalog.Participant{ID: peer, Kind: catalog.ParticipantUnknown, Label: rawPeerLabel(call)}
598
+ }
599
+
600
+ func peerOf(call gohttp.Call, opts Options) (string, catalog.Status) {
601
+ if call.API != "" {
602
+ if peer := opts.Peers[call.API]; peer != "" {
603
+ return peer, catalog.StatusDeclared
604
+ }
605
+ if peer := firstNonEmpty(opts.Externals[call.API], call.External); peer != "" {
606
+ return peer, catalog.StatusDeclared
607
+ }
608
+ }
609
+ return rawPeer(call), catalog.StatusUnresolved
610
+ }
611
+
612
+ func rawPeer(call gohttp.Call) string {
613
+ if parsed, err := url.Parse(call.Endpoint); err == nil && parsed.Hostname() != "" {
614
+ return slug(parsed.Hostname())
615
+ }
616
+ if call.Protocol == "SOAP" {
617
+ if parsed, err := url.Parse(call.Action); err == nil && parsed.Hostname() != "" {
618
+ return slug(parsed.Hostname())
619
+ }
620
+ return "soap-peer"
621
+ }
622
+ return "http-peer"
623
+ }
624
+
625
+ func rawPeerLabel(call gohttp.Call) string {
626
+ if call.Endpoint != "" {
627
+ return call.Endpoint
628
+ }
629
+ if call.Protocol == "SOAP" {
630
+ if call.Action != "" {
631
+ if parsed, err := url.Parse(call.Action); err == nil && parsed.Hostname() != "" {
632
+ return parsed.Hostname()
633
+ }
634
+ return call.Action
635
+ }
636
+ return "SOAP endpoint"
637
+ }
638
+ return "HTTP endpoint"
639
+ }
640
+
641
+ func callLabel(call gohttp.Call) string {
642
+ if call.Protocol == "SOAP" {
643
+ return "SOAP " + call.Action
644
+ }
645
+ return strings.TrimSpace(call.Method + " " + call.Path)
646
+ }
647
+
648
+ func callNote(call gohttp.Call) string {
649
+ var parts []string
650
+ if call.Endpoint != "" && call.Endpoint != call.Path {
651
+ parts = append(parts, "endpoint '"+call.Endpoint+"'")
652
+ }
653
+ if call.Request != "" {
654
+ parts = append(parts, "request '"+call.Request+"'")
655
+ }
656
+ if call.Response != "" {
657
+ parts = append(parts, "response '"+call.Response+"'")
658
+ }
659
+ if len(call.URLTrace) > 1 {
660
+ parts = append(parts, "URL source "+strings.Join(call.URLTrace, " → "))
661
+ }
662
+ return strings.Join(parts, "; ")
663
+ }
664
+
665
+ func slug(value string) string {
666
+ var out strings.Builder
667
+ dash := false
668
+ for _, r := range strings.ToLower(value) {
669
+ if unicode.IsLetter(r) || unicode.IsDigit(r) {
670
+ out.WriteRune(r)
671
+ dash = false
672
+ } else if out.Len() > 0 && !dash {
673
+ out.WriteByte('-')
674
+ dash = true
675
+ }
676
+ }
677
+ return strings.Trim(out.String(), "-")
678
+ }
679
+
680
+ func title(value string) string {
681
+ var words []string
682
+ for _, word := range strings.FieldsFunc(value, func(r rune) bool { return r == '-' || r == '_' || unicode.IsSpace(r) }) {
683
+ runes := []rune(word)
684
+ if len(runes) > 0 {
685
+ runes[0] = unicode.ToUpper(runes[0])
686
+ }
687
+ words = append(words, string(runes))
688
+ }
689
+ return strings.Join(words, " ")
690
+ }
691
+
692
+ func firstNonEmpty(values ...string) string {
693
+ for _, value := range values {
694
+ if value != "" {
695
+ return value
696
+ }
697
+ }
698
+ return ""
699
+ }
700
+
701
+ func uniqueStrings(values []string) []string {
702
+ seen := map[string]bool{}
703
+ out := make([]string, 0, len(values))
704
+ for _, value := range values {
705
+ if value != "" && !seen[value] {
706
+ seen[value] = true
707
+ out = append(out, value)
708
+ }
709
+ }
710
+ return out
711
+ }
712
+
713
+ // appendEvidence adds the evidence of another call site to an aggregated
714
+ // consumer entry, skipping items it already carries.
715
+ func appendEvidence(existing, more []catalog.RelationEvidence) []catalog.RelationEvidence {
716
+ for _, item := range more {
717
+ duplicate := false
718
+ for _, have := range existing {
719
+ if have.Kind == item.Kind && have.Rule == item.Rule && have.Source == item.Source && have.Symbol == item.Symbol {
720
+ duplicate = true
721
+ break
722
+ }
723
+ }
724
+ if !duplicate {
725
+ existing = append(existing, item)
726
+ }
727
+ }
728
+ return existing
729
+ }
730
+
731
+ func httpCallEvidence(call gohttp.Call) []catalog.RelationEvidence {
732
+ out := []catalog.RelationEvidence{{Kind: "call-site", Rule: "http-expression", Source: call.Source.String(), Symbol: call.Method + " " + call.Path}}
733
+ for _, function := range call.Chain {
734
+ out = append(out, catalog.RelationEvidence{Kind: "function", Rule: "analyzed-call-path", Symbol: function})
735
+ }
736
+ if call.Contract != "" {
737
+ out = append(out, catalog.RelationEvidence{Kind: "contract", Rule: "client-contract", Source: call.Contract, Symbol: call.ID})
738
+ }
739
+ return out
740
+ }