@shortlink-org/portolan 0.2.4 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/README.md +23 -0
  2. package/catalog/enum_test.go +46 -0
  3. package/catalog/evidence_test.go +35 -0
  4. package/catalog/model.go +1066 -0
  5. package/catalog/roundtrip_test.go +203 -0
  6. package/catalog/via_test.go +38 -0
  7. package/cli/init.test.mjs +6 -1
  8. package/cli/portolan.mjs +8 -0
  9. package/cli/portolan.test.mjs +49 -0
  10. package/go.mod +14 -0
  11. package/go.sum +20 -0
  12. package/internal/gocall/README.md +19 -0
  13. package/internal/gocall/analyze.go +189 -0
  14. package/internal/gocall/analyze_test.go +107 -0
  15. package/internal/gohttp/analyze.go +2562 -0
  16. package/internal/gohttp/destination.go +373 -0
  17. package/internal/gohttp/endpoints.go +1067 -0
  18. package/internal/gohttp/roots.go +320 -0
  19. package/internal/gohttp/typed.go +96 -0
  20. package/internal/goscan/constants.go +85 -0
  21. package/internal/goscan/goscan_test.go +227 -0
  22. package/internal/goscan/index.go +629 -0
  23. package/internal/goscan/index_test.go +66 -0
  24. package/internal/goscan/names.go +52 -0
  25. package/internal/goscan/parse_test.go +11 -0
  26. package/internal/goscan/source.go +37 -0
  27. package/internal/goscan/tree.go +284 -0
  28. package/internal/goscan/types.go +99 -0
  29. package/internal/wsdl/ids.go +127 -0
  30. package/internal/wsdl/ids_test.go +21 -0
  31. package/internal/wsdl/model.go +70 -0
  32. package/internal/wsdl/parse.go +949 -0
  33. package/internal/wsdl/parse_test.go +170 -0
  34. package/package.json +22 -10
  35. package/plugin/describe.go +118 -0
  36. package/plugin/describe_test.go +114 -0
  37. package/plugin/protocol.go +141 -0
  38. package/plugin/schematest/schematest.go +126 -0
  39. package/plugins/README.md +109 -46
  40. package/plugins/cmd/portolan-http-clients/main.go +19 -0
  41. package/plugins/extract-celery/extract.py +0 -2
  42. package/plugins/extract-celery/extract_test.py +1 -1
  43. package/plugins/extract-django/README.md +39 -17
  44. package/plugins/extract-django/domain.py +28 -17
  45. package/plugins/extract-django/extract.py +21 -7
  46. package/plugins/extract-django/extract_test.py +55 -2
  47. package/plugins/extract-django/lifecycle.py +2 -0
  48. package/plugins/extract-django/operations.py +1 -1
  49. package/plugins/extract-django/routing_test.py +109 -1
  50. package/plugins/extract-django/store.py +1 -1
  51. package/plugins/extract-django/transport.py +101 -55
  52. package/plugins/extract-django/verbs.py +241 -0
  53. package/plugins/extract-go/README.md +47 -0
  54. package/plugins/extract-http-clients/describe.go +19 -0
  55. package/plugins/extract-http-clients/describe_test.go +11 -0
  56. package/plugins/extract-http-clients/extract.go +740 -0
  57. package/plugins/extract-http-clients/extract_test.go +1561 -0
  58. package/plugins/extract-http-clients/main.go +41 -0
  59. package/plugins/extract-java/build/org/portolan/extract/Extract.class +0 -0
  60. package/plugins/extract-java/build/org/portolan/extract/Protocol$Builder.class +0 -0
  61. package/plugins/extract-java/build/org/portolan/extract/Protocol$Input.class +0 -0
  62. package/plugins/extract-java/build/org/portolan/extract/Protocol$Options.class +0 -0
  63. package/plugins/extract-python-kafka/extract.py +0 -2
  64. package/plugins/extract-python-kafka/extract_test.py +1 -1
  65. package/plugins/extract-ts/extract.test.ts +2 -2
  66. package/plugins/extract-ts/extract.ts +4 -5
  67. package/plugins/extract-ts/graphql.test.ts +1 -1
  68. package/plugins/openapi/ids.go +261 -0
  69. package/plugins/openapi/ids_test.go +98 -0
  70. package/plugins/portolan-go.wasm +0 -0
  71. package/plugins/pyplugin/protocol.py +1 -5
  72. package/portolan.json +3 -2
  73. package/schema/portolan.schema.json +34 -0
  74. package/scripts/README.md +18 -12
  75. package/scripts/catalog-sources.mjs +6 -0
  76. package/scripts/delivery-presets.mjs +21 -11
  77. package/scripts/diff.mjs +5 -1
  78. package/scripts/django-aggregates.test.mjs +58 -0
  79. package/scripts/gen-likec4.mjs +1 -1
  80. package/scripts/gen.mjs +118 -115
  81. package/scripts/go-discovery.test.mjs +30 -0
  82. package/scripts/history.mjs +186 -3
  83. package/scripts/history.test.mjs +1 -1
  84. package/scripts/host-plugins/fetch-git.mjs +77 -21
  85. package/scripts/host-plugins/fetch-git.test.mjs +62 -8
  86. package/scripts/local-api.mjs +71 -4
  87. package/scripts/local-api.test.mjs +63 -4
  88. package/scripts/local-discovery.mjs +82 -9
  89. package/scripts/manifest.mjs +5 -3
  90. package/scripts/manifest.test.mjs +24 -0
  91. package/scripts/output-diff.mjs +94 -0
  92. package/scripts/output-diff.test.mjs +36 -0
  93. package/scripts/package-smoke.mjs +62 -4
  94. package/scripts/plugin-host.mjs +22 -2
  95. package/scripts/plugin-host.test.mjs +9 -0
  96. package/scripts/plugin-wasm-worker.mjs +4 -1
  97. package/scripts/provenance.mjs +72 -0
  98. package/scripts/provenance.test.mjs +149 -0
  99. package/scripts/run-builtin.mjs +39 -5
  100. package/scripts/schema.mjs +29 -0
  101. package/scripts/warning-policy.mjs +161 -0
  102. package/scripts/warning-policy.test.mjs +56 -0
  103. package/src/app/Sidebar.tsx +3 -3
  104. package/src/catalog-docs.test.ts +64 -0
  105. package/src/catalog-docs.ts +35 -0
  106. package/src/catalog-error.test.ts +15 -0
  107. package/src/catalog-model.ts +48 -5
  108. package/src/catalog-validation.ts +9 -0
  109. package/src/chat/Starter.tsx +5 -11
  110. package/src/chat/tools.test.ts +27 -0
  111. package/src/chat/tools.ts +5 -9
  112. package/src/components/CatalogStamp.tsx +10 -8
  113. package/src/components/HTTPDestinationEvidence.test.tsx +23 -0
  114. package/src/components/HTTPDestinationEvidence.tsx +31 -0
  115. package/src/components/Integrations.tsx +1 -1
  116. package/src/components/MachineDocs.tsx +6 -5
  117. package/src/components/MethodRows.tsx +9 -2
  118. package/src/components/RelationEvidence.test.tsx +14 -0
  119. package/src/components/RelationEvidence.tsx +53 -0
  120. package/src/data.ts +25 -7
  121. package/src/enrich.test.ts +332 -1
  122. package/src/enrich.ts +206 -3
  123. package/src/flow/StepDetail.tsx +6 -0
  124. package/src/flow/evidence.test.ts +16 -0
  125. package/src/flow/evidence.ts +34 -0
  126. package/src/index.css +44 -0
  127. package/src/landing/DraggableReveal.tsx +3 -2
  128. package/src/landing/EvidencePipeline.tsx +105 -0
  129. package/src/landing/LandingPage.tsx +2 -59
  130. package/src/lib/catalog-diff.ts +1 -1
  131. package/src/lib/django-aggregates.d.mts +9 -0
  132. package/src/lib/django-aggregates.mjs +36 -0
  133. package/src/lib/django-aggregates.test.ts +29 -0
  134. package/src/lib/django-aggregates.ts +5 -0
  135. package/src/lib/local-api.ts +20 -2
  136. package/src/lib/setup-info.test.ts +17 -0
  137. package/src/lib/setup-info.ts +58 -0
  138. package/src/lib/warnings.test.ts +54 -0
  139. package/src/lib/warnings.ts +260 -0
  140. package/src/map/ContextMapGraph.tsx +76 -32
  141. package/src/merge.ts +16 -9
  142. package/src/pages/AggregatePage.tsx +8 -7
  143. package/src/pages/ContextPage.tsx +6 -5
  144. package/src/pages/ServicePage.tsx +4 -3
  145. package/src/pages/Settings.tsx +189 -41
  146. package/src/pages/settings/DjangoAggregateChoices.tsx +79 -0
  147. package/src/selection/DetailPanel.tsx +15 -0
  148. package/src/virtual-provenance.d.ts +11 -0
  149. package/vite.config.ts +5 -0
  150. package/scripts/vendor-lock.mjs +0 -58
  151. package/scripts/vendor-lock.test.mjs +0 -69
@@ -0,0 +1,1561 @@
1
+ package extracthttpclients
2
+
3
+ import (
4
+ "encoding/json"
5
+ "fmt"
6
+ "os"
7
+ "path/filepath"
8
+ "strings"
9
+ "testing"
10
+
11
+ "github.com/shortlink-org/portolan/catalog"
12
+ "github.com/shortlink-org/portolan/internal/gohttp"
13
+ "github.com/shortlink-org/portolan/plugin"
14
+ )
15
+
16
+ func writeHTTPFixture(t *testing.T, root, name, contents string) {
17
+ t.Helper()
18
+ path := filepath.Join(root, name)
19
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
20
+ t.Fatal(err)
21
+ }
22
+ if err := os.WriteFile(path, []byte(contents), 0o644); err != nil {
23
+ t.Fatal(err)
24
+ }
25
+ }
26
+
27
+ func TestExtractsRawGeneratedAndSOAPClients(t *testing.T) {
28
+ root := t.TempDir()
29
+ writeHTTPFixture(t, root, "go.mod", "module example.com/gateway\n")
30
+ writeHTTPFixture(t, root, "partner/openapi.yaml", `openapi: 3.0.3
31
+ info:
32
+ title: Partner API
33
+ version: 1.2.0
34
+ paths:
35
+ /v1/trips/{tripId}:
36
+ get:
37
+ operationId: getTrip
38
+ tags: [trips]
39
+ `)
40
+ writeHTTPFixture(t, root, "partner/client.gen.go", `// Code generated by oapi-codegen; DO NOT EDIT.
41
+ package partner
42
+ import (
43
+ "context"
44
+ "fmt"
45
+ "net/http"
46
+ )
47
+ type Client struct{}
48
+ type ClientInterface interface { GetTrip(context.Context, string) (*http.Response, error) }
49
+ func NewGetTripRequest(server, tripID string) (*http.Request, error) {
50
+ operationPath := fmt.Sprintf("/v1/trips/%s", tripID)
51
+ return http.NewRequest(http.MethodGet, server+operationPath, nil)
52
+ }
53
+
54
+ `)
55
+ writeHTTPFixture(t, root, "partner/adapter.go", `package partner
56
+ import "context"
57
+ func Load(ctx context.Context, c *Client, id string) error {
58
+ _, err := c.GetTripWithResponse(ctx, id)
59
+ return err
60
+ }
61
+ `)
62
+ writeHTTPFixture(t, root, "core/update.go", `package core
63
+ import (
64
+ "bytes"
65
+ "context"
66
+ "net/http"
67
+ )
68
+ const updatePath = "/v1/update"
69
+ func Update(ctx context.Context, baseURL string, body []byte) error {
70
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+updatePath, bytes.NewReader(body))
71
+ if err != nil { return err }
72
+ _ = req
73
+ return nil
74
+ }
75
+ `)
76
+ writeHTTPFixture(t, root, "transport/handler.go", `package transport
77
+ import (
78
+ "context"
79
+ "example.com/gateway/core"
80
+ )
81
+ func Handle(ctx context.Context, baseURL string, body []byte) error {
82
+ return core.Update(ctx, baseURL, body)
83
+ }
84
+ `)
85
+ writeHTTPFixture(t, root, "soap/cancel.go", `package soapclient
86
+ import (
87
+ "context"
88
+ soap "github.com/hooklift/gowsdl/soap"
89
+ )
90
+ const CancelAction = "urn:CancelBooking"
91
+ func Cancel(ctx context.Context, client soap.HTTPClient, request, response any) error {
92
+ if request == nil {
93
+ return nil
94
+ }
95
+ return client.CallContext(ctx, CancelAction, request, response)
96
+ }
97
+ `)
98
+ writeHTTPFixture(t, root, "soap/booking.wsdl", `<?xml version="1.0"?>
99
+ <definitions name="BookingService" targetNamespace="urn:booking"
100
+ xmlns:soap="http://schemas.xmlsoap.org/wsdl/soap/"
101
+ xmlns:tns="urn:booking"
102
+ xmlns:xsd="http://www.w3.org/2001/XMLSchema"
103
+ xmlns="http://schemas.xmlsoap.org/wsdl/">
104
+ <message name="CancelRequest"><part name="body" type="xsd:string"/></message>
105
+ <message name="CancelResponse"><part name="body" type="xsd:string"/></message>
106
+ <portType name="BookingPortType">
107
+ <operation name="CancelBooking"><input message="tns:CancelRequest"/><output message="tns:CancelResponse"/></operation>
108
+ </portType>
109
+ <binding name="BookingBinding" type="tns:BookingPortType">
110
+ <soap:binding style="document"/>
111
+ <operation name="CancelBooking">
112
+ <soap:operation soapAction="urn:CancelBooking"/>
113
+ </operation>
114
+ </binding>
115
+ <service name="BookingService">
116
+ <port name="BookingPort" binding="tns:BookingBinding">
117
+ <soap:address location="https://booking.example/soap"/>
118
+ </port>
119
+ </service>
120
+ </definitions>
121
+ `)
122
+
123
+ resp, err := extract(plugin.Input{Root: root}, Options{Context: "edge", Service: "gateway"})
124
+ if err != nil {
125
+ t.Fatal(err)
126
+ }
127
+ var got catalog.Catalog
128
+ if err := json.Unmarshal([]byte(resp.Files[0].Contents), &got); err != nil {
129
+ t.Fatal(err)
130
+ }
131
+ service := got.Contexts[0].Services[0]
132
+ if len(service.Consumes) != 3 {
133
+ t.Fatalf("consumes = %+v", service.Consumes)
134
+ }
135
+ var generated, raw, soapCall *catalog.RpcCall
136
+ for i := range service.Consumes {
137
+ call := &service.Consumes[i]
138
+ switch {
139
+ case strings.Contains(call.ID, "getTrip"):
140
+ generated = call
141
+ case strings.Contains(call.ID, "POST /v1/update"):
142
+ raw = call
143
+ case strings.Contains(call.ID, "CancelBooking"):
144
+ soapCall = call
145
+ }
146
+ }
147
+ if generated == nil || generated.Status != catalog.StatusDeclared || generated.Peer != "partner" {
148
+ t.Fatalf("generated = %+v", generated)
149
+ }
150
+ if raw == nil || raw.Status != catalog.StatusUnresolved || raw.Source != "core/update.go:9" {
151
+ t.Fatalf("raw = %+v", raw)
152
+ }
153
+ if soapCall == nil || soapCall.Status != catalog.StatusDeclared || soapCall.Peer != "booking" {
154
+ t.Fatalf("soap = %+v", soapCall)
155
+ }
156
+ if len(got.Externals) != 2 {
157
+ t.Fatalf("externals = %+v", got.Externals)
158
+ }
159
+ if len(got.Flows) != 4 {
160
+ t.Fatalf("flows = %+v", got.Flows)
161
+ }
162
+ var sawCondition, sawChain bool
163
+ for _, flow := range got.Flows {
164
+ for _, node := range flow.Steps {
165
+ if step, ok := node.(*catalog.Step); ok && strings.Contains(step.Note, "request == nil") {
166
+ sawCondition = true
167
+ }
168
+ if step, ok := node.(*catalog.Step); ok && strings.Contains(step.Note, "Handle → Update") {
169
+ sawChain = true
170
+ }
171
+ }
172
+ }
173
+ if !sawCondition {
174
+ t.Fatal("SOAP source condition was not preserved")
175
+ }
176
+ if !sawChain {
177
+ t.Fatal("generic entry point was not followed to its outbound call")
178
+ }
179
+ }
180
+
181
+ func TestFlowTracesConfigThroughConstructorFieldAndWrapper(t *testing.T) {
182
+ root := t.TempDir()
183
+ writeHTTPFixture(t, root, "go.mod", "module example.com/payments\n")
184
+ writeHTTPFixture(t, root, "client/client.go", `package client
185
+ import (
186
+ "context"
187
+ "net/http"
188
+ "net/url"
189
+ )
190
+ type Config struct { BaseURL string }
191
+ type Client struct { baseURL url.URL }
192
+ func Build(config map[string]string) (*Client, error) {
193
+ return New(Config{BaseURL: config["url"]})
194
+ }
195
+ func New(cfg Config) (*Client, error) {
196
+ parsed, err := url.Parse(cfg.BaseURL)
197
+ if err != nil { return nil, err }
198
+ return &Client{baseURL: *parsed}, nil
199
+ }
200
+ func (c *Client) request(ctx context.Context, method, path string) error {
201
+ req, err := http.NewRequestWithContext(ctx, method, c.baseURL.String()+path, nil)
202
+ if err != nil { return err }
203
+ _ = req
204
+ return nil
205
+ }
206
+ func (c *Client) Search(ctx context.Context) error {
207
+ return c.request(ctx, http.MethodPost, "/v1/search")
208
+ }
209
+ `)
210
+
211
+ resp, err := extract(plugin.Input{Root: root}, Options{Context: "pay", Service: "gateway"})
212
+ if err != nil {
213
+ t.Fatal(err)
214
+ }
215
+ var got catalog.Catalog
216
+ if err := json.Unmarshal([]byte(resp.Files[0].Contents), &got); err != nil {
217
+ t.Fatal(err)
218
+ }
219
+ if len(got.Flows) != 1 {
220
+ t.Fatalf("flows = %+v", got.Flows)
221
+ }
222
+ flow := got.Flows[0]
223
+ if flow.Name != "Client Search → outbound APIs" {
224
+ t.Fatalf("flow name = %q", flow.Name)
225
+ }
226
+ if flow.EntryPoint != "client:Client.Search" {
227
+ t.Fatalf("flow entrypoint = %q", flow.EntryPoint)
228
+ }
229
+ step, ok := flow.Steps[0].(*catalog.Step)
230
+ if !ok {
231
+ t.Fatalf("step = %#v", flow.Steps[0])
232
+ }
233
+ if step.Label != "POST /v1/search" {
234
+ t.Fatalf("step label = %q", step.Label)
235
+ }
236
+ if consumes := got.Contexts[0].Services[0].Consumes; len(consumes) != 1 || consumes[0].ID != "http-client/POST /v1/search" {
237
+ t.Fatalf("consumes = %+v", consumes)
238
+ }
239
+ if !strings.Contains(step.Note, `config["url"] → Build → New → Client.baseURL → request URL`) {
240
+ t.Fatalf("step note = %q", step.Note)
241
+ }
242
+ if !strings.Contains(step.Note, "Client.Search → Client.request") {
243
+ t.Fatalf("step chain = %q", step.Note)
244
+ }
245
+ }
246
+
247
+ func TestFlowKeepsDistinctCallsThroughClosureArguments(t *testing.T) {
248
+ root := t.TempDir()
249
+ writeHTTPFixture(t, root, "go.mod", "module example.com/closure\n")
250
+ writeHTTPFixture(t, root, "client.go", `package closure
251
+ import (
252
+ "context"
253
+ "net/http"
254
+ )
255
+ func request(ctx context.Context, path string) error {
256
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://example.test"+path, nil)
257
+ if err != nil { return err }
258
+ _ = req
259
+ return nil
260
+ }
261
+ func Refresh(ctx context.Context) {
262
+ go func(path string) { _ = request(ctx, path) }("/v1/primary")
263
+ go func(path string) { _ = request(ctx, path) }("/v1/secondary")
264
+ }
265
+ `)
266
+
267
+ resp, err := extract(plugin.Input{Root: root}, Options{Context: "ops", Service: "refresher"})
268
+ if err != nil {
269
+ t.Fatal(err)
270
+ }
271
+ var got catalog.Catalog
272
+ if err := json.Unmarshal([]byte(resp.Files[0].Contents), &got); err != nil {
273
+ t.Fatal(err)
274
+ }
275
+ if len(got.Flows) != 1 {
276
+ t.Fatalf("flows = %+v", got.Flows)
277
+ }
278
+ var labels []string
279
+ for _, node := range got.Flows[0].Steps {
280
+ if step, ok := node.(*catalog.Step); ok {
281
+ labels = append(labels, step.Label)
282
+ }
283
+ }
284
+ if strings.Join(labels, ",") != "GET /v1/primary,GET /v1/secondary" {
285
+ t.Fatalf("labels = %+v", labels)
286
+ }
287
+ }
288
+
289
+ func TestFlowDoesNotLinkMethodsByNameAlone(t *testing.T) {
290
+ root := t.TempDir()
291
+ writeHTTPFixture(t, root, "go.mod", "module example.com/close\n")
292
+ writeHTTPFixture(t, root, "client.go", `package close
293
+ import "net/http"
294
+ type LocalCloser struct{}
295
+ func (LocalCloser) Close() error {
296
+ _, err := http.Get("https://audit.example/v1/closed")
297
+ return err
298
+ }
299
+ func Fetch() error {
300
+ response, err := http.Get("https://data.example/v1/items")
301
+ if err != nil { return err }
302
+ defer response.Body.Close()
303
+ return nil
304
+ }
305
+ `)
306
+
307
+ resp, err := extract(plugin.Input{Root: root}, Options{Context: "edge", Service: "reader"})
308
+ if err != nil {
309
+ t.Fatal(err)
310
+ }
311
+ var got catalog.Catalog
312
+ if err := json.Unmarshal([]byte(resp.Files[0].Contents), &got); err != nil {
313
+ t.Fatal(err)
314
+ }
315
+ for _, flow := range got.Flows {
316
+ if flow.Name != "Fetch → outbound APIs" {
317
+ continue
318
+ }
319
+ if len(flow.Steps) != 1 {
320
+ t.Fatalf("Fetch steps = %+v", flow.Steps)
321
+ }
322
+ step := flow.Steps[0].(*catalog.Step)
323
+ if step.Label != "GET /v1/items" {
324
+ t.Fatalf("Fetch step = %+v", step)
325
+ }
326
+ return
327
+ }
328
+ t.Fatalf("Fetch flow missing: %+v", got.Flows)
329
+ }
330
+
331
+ func TestBuildsEndpointRootedProviderFlowFromSource(t *testing.T) {
332
+ root := t.TempDir()
333
+ writeHTTPFixture(t, root, "go.mod", "module example.com/travel\n")
334
+ writeHTTPFixture(t, root, "app/main.go", `package app
335
+ import (
336
+ "example.com/travel/actions/search"
337
+ "example.com/travel/connector"
338
+ )
339
+ type Router struct{}
340
+ func (*Router) POST(string, func()) {}
341
+ type Requester interface { ConnExec(connector.API) }
342
+ func Start(r *Router) {
343
+ r.POST("/search", func() { SearchAction() })
344
+ app := App{}
345
+ r.POST("/direct-search", app.DirectSearch)
346
+ }
347
+ type App struct{}
348
+ func (App) DirectSearch() {
349
+ conn := connector.BuildConnector("runtime-name")
350
+ conn.Search()
351
+ }
352
+ func SearchAction() {
353
+ request := &search.Request{}
354
+ ActionFlow(request)
355
+ }
356
+ func ActionFlow(request Requester) {
357
+ conn := connector.BuildConnector("runtime-name")
358
+ request.ConnExec(conn)
359
+ }
360
+ `)
361
+ writeHTTPFixture(t, root, "actions/search/request.go", `package search
362
+ import "example.com/travel/connector"
363
+ type Request struct{}
364
+ func (*Request) ConnExec(conn connector.API) { conn.Search() }
365
+ `)
366
+ writeHTTPFixture(t, root, "connector/factory.go", `package connector
367
+ import (
368
+ "example.com/travel/provider/alpha"
369
+ "example.com/travel/provider/beta"
370
+ "example.com/travel/provider/opaque"
371
+ )
372
+ type API interface { Search() }
373
+ func BuildConnector(name string) API {
374
+ switch name {
375
+ case "alpha":
376
+ return alpha.New()
377
+ case "beta":
378
+ client := beta.New()
379
+ return client
380
+ case "opaque":
381
+ return opaque.New()
382
+ default:
383
+ return nil
384
+ }
385
+ }
386
+ `)
387
+ writeHTTPFixture(t, root, "provider/opaque/client.go", `package opaque
388
+ type Connector struct{}
389
+ func New() *Connector { return &Connector{} }
390
+ func (*Connector) Search() {}
391
+ `)
392
+ writeHTTPFixture(t, root, "provider/alpha/client.go", `package alpha
393
+ import (
394
+ alphaclient "example.com/travel/provider/alpha/client"
395
+ alphaport "example.com/travel/provider/alpha/port"
396
+ )
397
+ type Connector struct{ Client alphaport.API }
398
+ func New() *Connector {
399
+ client := alphaclient.New()
400
+ return &Connector{Client: client}
401
+ }
402
+ func (c *Connector) Search() {
403
+ c.Client.Search()
404
+ c.Client.Retrieve()
405
+ }
406
+ `)
407
+ writeHTTPFixture(t, root, "provider/alpha/port/api.go", `package port
408
+ type API interface {
409
+ Search()
410
+ Retrieve()
411
+ }
412
+ `)
413
+ writeHTTPFixture(t, root, "provider/alpha/client/client.go", `package client
414
+ import "net/http"
415
+ type Client struct{}
416
+ func New() *Client { return &Client{} }
417
+ func (c *Client) Search() { c.SearchRequest() }
418
+ func (*Client) SearchRequest() { _, _ = http.Get("https://alpha.example/v1/search") }
419
+ func (c *Client) Retrieve() { c.RetrieveRequest() }
420
+ func (*Client) RetrieveRequest() { _, _ = http.Get("https://alpha.example/v1/retrieve") }
421
+ `)
422
+ writeHTTPFixture(t, root, "provider/beta/client.go", `package beta
423
+ import betaclient "example.com/travel/provider/beta/client"
424
+ type Connector struct{ Client *betaclient.Client }
425
+ func New() *Connector { return &Connector{Client: &betaclient.Client{}} }
426
+ func (c *Connector) Search() { c.Client.Search() }
427
+ `)
428
+ writeHTTPFixture(t, root, "provider/beta/client/client.go", `package client
429
+ import "net/http"
430
+ type Client struct{}
431
+ func (*Client) Search() { _, _ = http.Get("https://beta.example/v2/offers") }
432
+ `)
433
+ writeHTTPFixture(t, root, "jobs/refresh.go", `package jobs
434
+ import "net/http"
435
+ func Refresh() { _, _ = http.Get("https://cache.example/refresh") }
436
+ `)
437
+
438
+ resp, err := extract(plugin.Input{Root: root}, Options{Context: "travel", Service: "search"})
439
+ if err != nil {
440
+ t.Fatal(err)
441
+ }
442
+ var got catalog.Catalog
443
+ if err := json.Unmarshal([]byte(resp.Files[0].Contents), &got); err != nil {
444
+ t.Fatal(err)
445
+ }
446
+ if len(got.Flows) != 3 {
447
+ t.Fatalf("flows = %+v", got.Flows)
448
+ }
449
+ var endpoint, direct, background *catalog.Flow
450
+ for index := range got.Flows {
451
+ flow := &got.Flows[index]
452
+ if flow.Name == "POST /search → provider APIs" {
453
+ endpoint = flow
454
+ }
455
+ if flow.Name == "POST /direct-search → provider APIs" {
456
+ direct = flow
457
+ }
458
+ if flow.Name == "Refresh → outbound APIs" {
459
+ background = flow
460
+ }
461
+ if strings.Contains(flow.Name, "Connector Search") {
462
+ t.Fatalf("covered provider fragment was retained: %+v", flow)
463
+ }
464
+ if flow.Name == "Alpha Search Request → outbound APIs" {
465
+ t.Fatalf("covered nested provider fragment was retained: %+v", flow)
466
+ }
467
+ }
468
+ if endpoint == nil || direct == nil || background == nil {
469
+ t.Fatalf("endpoint = %+v, direct = %+v, background = %+v", endpoint, direct, background)
470
+ }
471
+ if !strings.Contains(background.Summary, "No source caller was found") {
472
+ t.Fatalf("standalone reason = %q", background.Summary)
473
+ }
474
+ if len(endpoint.Steps) != 2 {
475
+ t.Fatalf("steps = %+v", endpoint.Steps)
476
+ }
477
+ inbound, ok := endpoint.Steps[0].(*catalog.Step)
478
+ if !ok || inbound.Label != "POST /search" || inbound.Line != "app/main.go:10" {
479
+ t.Fatalf("inbound = %+v", endpoint.Steps[0])
480
+ }
481
+ providers, ok := endpoint.Steps[1].(*catalog.Alt)
482
+ if !ok || len(providers.Branches) != 3 {
483
+ t.Fatalf("providers = %+v", endpoint.Steps[1])
484
+ }
485
+ if providers.Branches[0].Title != `connector = "alpha"` || providers.Branches[1].Title != `connector = "beta"` || providers.Branches[2].Title != `connector = "opaque"` {
486
+ t.Fatalf("branches = %+v", providers.Branches)
487
+ }
488
+ alphaStep := providers.Branches[0].Steps[0].(*catalog.Step)
489
+ betaStep := providers.Branches[1].Steps[0].(*catalog.Step)
490
+ if alphaStep.Label != "GET /v1/search" || betaStep.Label != "GET /v2/offers" {
491
+ t.Fatalf("provider steps = %+v / %+v", alphaStep, betaStep)
492
+ }
493
+ if len(providers.Branches[0].Steps) != 2 || providers.Branches[0].Steps[1].(*catalog.Step).Label != "GET /v1/retrieve" {
494
+ t.Fatalf("constructor-wired call order = %+v", providers.Branches[0].Steps)
495
+ }
496
+ if !strings.Contains(alphaStep.Note, "Connector.Search → Client.Search → Client.SearchRequest") {
497
+ t.Fatalf("nested provider chain = %+v", alphaStep)
498
+ }
499
+ if !strings.Contains(betaStep.Note, "Connector.Search → Client.Search") {
500
+ t.Fatalf("concrete imported client chain = %+v", betaStep)
501
+ }
502
+ opaqueStep := providers.Branches[2].Steps[0].(*catalog.Step)
503
+ if opaqueStep.Kind != catalog.StepCall || opaqueStep.Label != "Opaque Search" || !strings.Contains(opaqueStep.Note, "outbound transport was not resolved") {
504
+ t.Fatalf("opaque step = %+v", opaqueStep)
505
+ }
506
+ }
507
+
508
+ func TestBuildsEndpointThroughMultiStageInterfaceDispatch(t *testing.T) {
509
+ root := t.TempDir()
510
+ writeHTTPFixture(t, root, "go.mod", "module example.com/typed\n\ngo 1.27.0\n")
511
+ writeHTTPFixture(t, root, "app/main.go", `package app
512
+ import (
513
+ "example.com/typed/actions/rules"
514
+ "example.com/typed/connector"
515
+ )
516
+ type Router struct{}
517
+ func (*Router) POST(string, func()) {}
518
+ type Requester interface { ConnExec(connector.API) }
519
+ func Start(r *Router) { r.POST("/rules", RulesAction) }
520
+ func RulesAction() {
521
+ request := &rules.Request{}
522
+ Dispatch(request)
523
+ }
524
+
525
+ func Dispatch(request Requester) { Invoke(request) }
526
+ func Invoke(request Requester) {
527
+ conn := connector.Build("runtime")
528
+ request.ConnExec(conn)
529
+ }
530
+ `)
531
+ writeHTTPFixture(t, root, "actions/rules/request.go", `package rules
532
+ import "example.com/typed/connector"
533
+ type Request struct{}
534
+ func (*Request) ConnExec(conn connector.API) { conn.Rules() }
535
+ `)
536
+ writeHTTPFixture(t, root, "connector/factory.go", `package connector
537
+ import "example.com/typed/provider/alpha"
538
+ type API interface { Rules() }
539
+ func Build(name string) API {
540
+ switch name {
541
+ case "alpha": return alpha.New()
542
+ default: return nil
543
+ }
544
+ }
545
+ `)
546
+ writeHTTPFixture(t, root, "provider/alpha/connector.go", `package alpha
547
+ import "example.com/typed/provider/alpha/client"
548
+ type rulesClient interface { FetchRules() }
549
+ type Connector struct { client rulesClient }
550
+ func New() *Connector { return &Connector{client: &client.Client{}} }
551
+ func (c *Connector) Rules() { c.client.FetchRules() }
552
+ `)
553
+ writeHTTPFixture(t, root, "provider/alpha/client/client.go", `package client
554
+ import "net/http"
555
+ type Client struct{}
556
+ func (c *Client) FetchRules() { c.fetchRules() }
557
+ func (c *Client) fetchRules() { c.finishRules() }
558
+ func (c *Client) finishRules() {
559
+ _, _ = http.Get("https://alpha.example/v1/rules")
560
+ if false { c.fetchRules() }
561
+ }
562
+ func (*Client) CheckRules() { _, _ = http.Get("https://alpha.example/v1/check-rules") }
563
+ `)
564
+
565
+ analysis, err := gohttp.Analyze(root)
566
+ if err != nil {
567
+ t.Fatal(err)
568
+ }
569
+ if !analysis.TypedCallGraph || analysis.TypedCallGraphError != "" {
570
+ t.Fatalf("typed analysis = %v / %q", analysis.TypedCallGraph, analysis.TypedCallGraphError)
571
+ }
572
+ if len(analysis.EndpointFlows) != 1 || len(analysis.EndpointFlows[0].Branches) != 1 {
573
+ t.Fatalf("endpoint flows = %+v", analysis.EndpointFlows)
574
+ }
575
+ branch := analysis.EndpointFlows[0].Branches[0]
576
+ if branch.Operation != "Rules" || len(branch.Calls) != 1 || branch.Calls[0].Path != "/v1/rules" {
577
+ t.Fatalf("typed provider branch = %+v", branch)
578
+ }
579
+
580
+ resp, err := extract(plugin.Input{Root: root}, Options{Context: "travel", Service: "rules"})
581
+ if err != nil {
582
+ t.Fatal(err)
583
+ }
584
+ var got catalog.Catalog
585
+ if err := json.Unmarshal([]byte(resp.Files[0].Contents), &got); err != nil {
586
+ t.Fatal(err)
587
+ }
588
+ var endpoint, unused *catalog.Flow
589
+ for index := range got.Flows {
590
+ flow := &got.Flows[index]
591
+ if flow.Name == "POST /rules → provider APIs" {
592
+ endpoint = flow
593
+ }
594
+ if strings.Contains(flow.Name, "CheckRules") {
595
+ unused = flow
596
+ }
597
+ if strings.Contains(flow.Name, "Fetch Rules") {
598
+ t.Fatalf("covered typed fragment retained: %+v", flow)
599
+ }
600
+ }
601
+ if endpoint == nil || unused == nil {
602
+ t.Fatalf("endpoint = %+v, unused = %+v, flows = %+v", endpoint, unused, got.Flows)
603
+ }
604
+ step := endpoint.Steps[1].(*catalog.Step)
605
+ if step.Label != "GET /v1/rules" || !strings.Contains(step.Note, "Connector.Rules → Client.FetchRules") {
606
+ t.Fatalf("typed step = %+v", step)
607
+ }
608
+ if unused.Trigger == nil || unused.Trigger.Kind != "unproven" {
609
+ t.Fatalf("unused trigger = %+v", unused.Trigger)
610
+ }
611
+ }
612
+
613
+ func TestTypedAnalysisMapsPositionsFromAManifestRelativeRoot(t *testing.T) {
614
+ root := t.TempDir()
615
+ writeHTTPFixture(t, root, "go.mod", "module example.com/relative\n\ngo 1.27.0\n")
616
+ writeHTTPFixture(t, root, "app/main.go", `package app
617
+ import (
618
+ "example.com/relative/actions/rules"
619
+ "example.com/relative/connector"
620
+ )
621
+ type Router struct{}
622
+ func (*Router) POST(string, func()) {}
623
+ type Requester interface { ConnExec(connector.API) }
624
+ func Start(r *Router) { r.POST("/rules", RulesAction) }
625
+ func RulesAction() { request := &rules.Request{}; Dispatch(request) }
626
+ func Dispatch(request Requester) { Invoke(request) }
627
+ func Invoke(request Requester) { request.ConnExec(connector.Build()) }
628
+ `)
629
+ writeHTTPFixture(t, root, "actions/rules/request.go", `package rules
630
+ import "example.com/relative/connector"
631
+ type Request struct{}
632
+ func (*Request) ConnExec(conn connector.API) { conn.Rules() }
633
+ `)
634
+ writeHTTPFixture(t, root, "connector/factory.go", `package connector
635
+ import "example.com/relative/provider/alpha"
636
+ type API interface { Rules() }
637
+ func Build() API { return alpha.New() }
638
+ `)
639
+ writeHTTPFixture(t, root, "provider/alpha/client.go", `package alpha
640
+ import "net/http"
641
+ type Client struct{}
642
+ func New() *Client { return &Client{} }
643
+ func (*Client) Rules() { _, _ = http.Get("https://alpha.example/v1/rules") }
644
+ `)
645
+
646
+ old, err := os.Getwd()
647
+ if err != nil {
648
+ t.Fatal(err)
649
+ }
650
+ if err := os.Chdir(root); err != nil {
651
+ t.Fatal(err)
652
+ }
653
+ t.Cleanup(func() { _ = os.Chdir(old) })
654
+
655
+ analysis, err := gohttp.Analyze(".")
656
+ if err != nil {
657
+ t.Fatal(err)
658
+ }
659
+ if !analysis.TypedCallGraph || analysis.TypedCallGraphError != "" {
660
+ t.Fatalf("typed analysis = %v / %q", analysis.TypedCallGraph, analysis.TypedCallGraphError)
661
+ }
662
+ if len(analysis.EndpointFlows) != 1 || len(analysis.EndpointFlows[0].Branches) != 1 {
663
+ t.Fatalf("endpoint flows = %+v", analysis.EndpointFlows)
664
+ }
665
+ branch := analysis.EndpointFlows[0].Branches[0]
666
+ if branch.Operation != "Rules" || len(branch.Calls) != 1 || branch.Calls[0].Path != "/v1/rules" {
667
+ t.Fatalf("typed provider branch = %+v", branch)
668
+ }
669
+ }
670
+
671
+ func TestPartialTypedAnalysisAndSyntaxFallback(t *testing.T) {
672
+ for _, dependent := range []bool{false, true} {
673
+ t.Run(fmt.Sprintf("broken_dependency_%v", dependent), func(t *testing.T) {
674
+ root := t.TempDir()
675
+ writeHTTPFixture(t, root, "go.mod", "module example.com/fallback\n\ngo 1.27.0\n")
676
+ writeHTTPFixture(t, root, "app/main.go", `package app
677
+ import (
678
+ "net/http"
679
+ "example.com/fallback/connector"
680
+ )
681
+ type Router struct{}
682
+ func (*Router) POST(string, func()) {}
683
+ func Start(r *Router) { r.POST("/search", Search) }
684
+ func Search() {
685
+ conn := connector.Build("runtime")
686
+ conn.Search()
687
+ _, _ = http.Get("https://fallback.example/search")
688
+ }
689
+ `)
690
+ writeHTTPFixture(t, root, "connector/factory.go", `package connector
691
+ import "example.com/fallback/provider/alpha"
692
+ type API interface { Search() }
693
+ func Build(name string) API {
694
+ switch name { case "only": return alpha.New(); default: return nil }
695
+ }
696
+ `)
697
+ writeHTTPFixture(t, root, "provider/alpha/connector.go", `package alpha
698
+ type Connector struct{}
699
+ func New() *Connector { return &Connector{} }
700
+ func (*Connector) Search() {}
701
+ `)
702
+ writeHTTPFixture(t, root, "broken/broken.go", `package broken
703
+ import _ "example.com/missing/private"
704
+ `)
705
+
706
+ if dependent {
707
+ writeHTTPFixture(t, root, "provider/alpha/broken.go", `package alpha
708
+ import _ "example.com/missing/private"
709
+ `)
710
+ }
711
+ analysis, err := gohttp.Analyze(root)
712
+ if err != nil {
713
+ t.Fatal(err)
714
+ }
715
+ if dependent {
716
+ if analysis.TypedCallGraph || analysis.TypedCallGraphError == "" {
717
+ t.Fatalf("typed fallback = %v / %q", analysis.TypedCallGraph, analysis.TypedCallGraphError)
718
+ }
719
+ } else {
720
+ if !analysis.TypedCallGraph || analysis.TypedCallGraphError != "" {
721
+ t.Fatalf("independent typed facts lost: %v / %q", analysis.TypedCallGraph, analysis.TypedCallGraphError)
722
+ }
723
+ if !strings.Contains(strings.Join(analysis.Warnings, "\n"), "typed call graph is partial") {
724
+ t.Fatalf("missing partial diagnostic: %v", analysis.Warnings)
725
+ }
726
+ }
727
+ if len(analysis.Calls) != 1 || analysis.Calls[0].Path != "/search" {
728
+ t.Fatalf("syntax calls = %+v", analysis.Calls)
729
+ }
730
+ })
731
+ }
732
+ }
733
+
734
+ func TestBuildsEndpointThroughFixedFactorySetterAndWrapper(t *testing.T) {
735
+ root := t.TempDir()
736
+ writeHTTPFixture(t, root, "go.mod", "module example.com/wired\n")
737
+ writeHTTPFixture(t, root, "app/main.go", `package app
738
+ import "example.com/wired/connector"
739
+ type Router struct{}
740
+ func (*Router) POST(string, func()) {}
741
+ func Start(r *Router) { r.POST("/cancel", Cancel) }
742
+ func Cancel() {
743
+ conn := connector.Build()
744
+ conn.Cancel()
745
+ }
746
+ `)
747
+ writeHTTPFixture(t, root, "connector/factory.go", `package connector
748
+ import (
749
+ "example.com/wired/provider/alpha"
750
+ "example.com/wired/provider/wrapper"
751
+ )
752
+ type API interface{ Cancel() }
753
+ func Build() API {
754
+ conn := wrapper.New()
755
+ client := alpha.New()
756
+ conn.SetClient(client)
757
+ return conn
758
+ }
759
+ `)
760
+ writeHTTPFixture(t, root, "provider/port/api.go", `package port
761
+ type API interface{ Cancel() }
762
+ `)
763
+ writeHTTPFixture(t, root, "provider/wrapper/connector.go", `package wrapper
764
+ import "example.com/wired/provider/port"
765
+ type Connector struct{ client port.API }
766
+ func New() *Connector { return &Connector{} }
767
+ func (c *Connector) SetClient(client port.API) { c.client = client }
768
+ func (c *Connector) Cancel() { c.client.Cancel() }
769
+ `)
770
+ writeHTTPFixture(t, root, "provider/alpha/client.go", `package alpha
771
+ import "net/http"
772
+ type Client struct{}
773
+ func New() *Client { return &Client{} }
774
+ func (*Client) Cancel() { _, _ = http.Get("https://alpha.example/v1/cancel") }
775
+ `)
776
+
777
+ resp, err := extract(plugin.Input{Root: root}, Options{Context: "travel", Service: "cancel"})
778
+ if err != nil {
779
+ t.Fatal(err)
780
+ }
781
+ var got catalog.Catalog
782
+ if err := json.Unmarshal([]byte(resp.Files[0].Contents), &got); err != nil {
783
+ t.Fatal(err)
784
+ }
785
+ if len(got.Flows) != 1 {
786
+ t.Fatalf("flows = %+v", got.Flows)
787
+ }
788
+ flow := got.Flows[0]
789
+ if flow.Name != "POST /cancel → provider APIs" || len(flow.Steps) != 2 {
790
+ t.Fatalf("endpoint = %+v", flow)
791
+ }
792
+ step, ok := flow.Steps[1].(*catalog.Step)
793
+ if !ok || step.Label != "GET /v1/cancel" {
794
+ t.Fatalf("provider step = %+v", flow.Steps[1])
795
+ }
796
+ if !strings.Contains(step.Note, "Connector.Cancel → Client.Cancel") {
797
+ t.Fatalf("setter-wired wrapper chain = %q", step.Note)
798
+ }
799
+ }
800
+
801
+ func TestBuildsEndpointThroughCapabilityAssertion(t *testing.T) {
802
+ root := t.TempDir()
803
+ writeHTTPFixture(t, root, "go.mod", "module example.com/capability\n")
804
+ writeHTTPFixture(t, root, "app/main.go", `package app
805
+ import (
806
+ "example.com/capability/actions/documents"
807
+ "example.com/capability/connector"
808
+ )
809
+ type Router struct{}
810
+ func (*Router) POST(string, func()) {}
811
+ type Requester interface{ ConnExec(connector.API) }
812
+ func Start(r *Router) { r.POST("/documents", DocumentsAction) }
813
+ func DocumentsAction() {
814
+ request := documents.Request{}
815
+ ActionFlow(&request)
816
+ }
817
+ func ActionFlow(request Requester) {
818
+ conn := connector.Build("runtime")
819
+ request.ConnExec(conn)
820
+ }
821
+ `)
822
+ writeHTTPFixture(t, root, "actions/documents/request.go", `package documents
823
+ import "example.com/capability/connector"
824
+ type Request struct{}
825
+ func (*Request) ConnExec(conn connector.API) {
826
+ getter, ok := conn.(interface{ GetDocuments() })
827
+ if ok { getter.GetDocuments() }
828
+ }
829
+ `)
830
+ writeHTTPFixture(t, root, "connector/factory.go", `package connector
831
+ import (
832
+ "example.com/capability/provider/alpha"
833
+ "example.com/capability/provider/beta"
834
+ )
835
+ type API interface{}
836
+ func Build(name string) API {
837
+ switch name {
838
+ case "alpha": return alpha.New()
839
+ case "beta": return beta.New()
840
+ default: return nil
841
+ }
842
+ }
843
+ `)
844
+ writeHTTPFixture(t, root, "provider/alpha/client.go", `package alpha
845
+ import "net/http"
846
+ type Client struct{}
847
+ func New() *Client { return &Client{} }
848
+ func (*Client) GetDocuments() { _, _ = http.Get("https://alpha.example/v1/documents") }
849
+ `)
850
+ writeHTTPFixture(t, root, "provider/beta/client.go", `package beta
851
+ type Client struct{}
852
+ func New() *Client { return &Client{} }
853
+ `)
854
+
855
+ resp, err := extract(plugin.Input{Root: root}, Options{Context: "travel", Service: "documents"})
856
+ if err != nil {
857
+ t.Fatal(err)
858
+ }
859
+ var got catalog.Catalog
860
+ if err := json.Unmarshal([]byte(resp.Files[0].Contents), &got); err != nil {
861
+ t.Fatal(err)
862
+ }
863
+ if len(got.Flows) != 1 {
864
+ t.Fatalf("flows = %+v", got.Flows)
865
+ }
866
+ flow := got.Flows[0]
867
+ if flow.Name != "POST /documents → provider APIs" || len(flow.Steps) != 2 {
868
+ t.Fatalf("endpoint = %+v", flow)
869
+ }
870
+ step, ok := flow.Steps[1].(*catalog.Step)
871
+ if !ok || step.Label != "GET /v1/documents" {
872
+ t.Fatalf("capability step = %+v", flow.Steps[1])
873
+ }
874
+ }
875
+
876
+ func TestBuildsProviderBranchesFromConstructorMap(t *testing.T) {
877
+ root := t.TempDir()
878
+ writeHTTPFixture(t, root, "go.mod", "module example.com/registry\n")
879
+ writeHTTPFixture(t, root, "app/main.go", `package app
880
+ import "example.com/registry/connector"
881
+ type Router struct{}
882
+ func (*Router) POST(string, func()) {}
883
+ func Start(r *Router) { r.POST("/search", Search) }
884
+ func Search() {
885
+ conn := connector.Build("runtime")
886
+ conn.Search()
887
+ }
888
+ `)
889
+ writeHTTPFixture(t, root, "connector/factory.go", `package connector
890
+ import (
891
+ "example.com/registry/provider/alpha"
892
+ "example.com/registry/provider/beta"
893
+ )
894
+ type API interface{ Search() }
895
+ var providers = map[string]func() API{
896
+ "alpha": func() API { return alpha.New() },
897
+ "beta": beta.New,
898
+ }
899
+ func Build(name string) API { return providers[name]() }
900
+ `)
901
+ writeHTTPFixture(t, root, "provider/alpha/client.go", `package alpha
902
+ import "net/http"
903
+ type Client struct{}
904
+ func New() *Client { return &Client{} }
905
+ func (*Client) Search() { _, _ = http.Get("https://alpha.example/search") }
906
+ `)
907
+ writeHTTPFixture(t, root, "provider/beta/client.go", `package beta
908
+ import "net/http"
909
+ type Client struct{}
910
+ func New() *Client { return &Client{} }
911
+ func (*Client) Search() { _, _ = http.Get("https://beta.example/search") }
912
+ `)
913
+
914
+ resp, err := extract(plugin.Input{Root: root}, Options{Context: "travel", Service: "search"})
915
+ if err != nil {
916
+ t.Fatal(err)
917
+ }
918
+ var got catalog.Catalog
919
+ if err := json.Unmarshal([]byte(resp.Files[0].Contents), &got); err != nil {
920
+ t.Fatal(err)
921
+ }
922
+ if len(got.Flows) != 1 {
923
+ t.Fatalf("flows = %+v", got.Flows)
924
+ }
925
+ flow := got.Flows[0]
926
+ providers, ok := flow.Steps[1].(*catalog.Alt)
927
+ if !ok || len(providers.Branches) != 2 {
928
+ t.Fatalf("provider map branches = %+v", flow.Steps)
929
+ }
930
+ if providers.Branches[0].Title != `connector = "alpha"` || providers.Branches[1].Title != `connector = "beta"` {
931
+ t.Fatalf("provider map titles = %+v", providers.Branches)
932
+ }
933
+ for _, branch := range providers.Branches {
934
+ step := branch.Steps[len(branch.Steps)-1].(*catalog.Step)
935
+ if len(step.Reaches) != 1 || !strings.HasSuffix(step.Reaches[0], ":Client.Search") {
936
+ t.Fatalf("provider reachability = %+v", step.Reaches)
937
+ }
938
+ }
939
+ }
940
+
941
+ func TestBuildsDirectHTTPRootThroughClosureAndLocalVariable(t *testing.T) {
942
+ root := t.TempDir()
943
+ writeHTTPFixture(t, root, "go.mod", "module example.com/refresh\n")
944
+ writeHTTPFixture(t, root, "app/routes.go", `package app
945
+ import "example.com/refresh/cache"
946
+ type Router struct{}
947
+ func (*Router) POST(string, func()) {}
948
+ func Routes(r *Router) {
949
+ state := cache.New()
950
+ r.POST("/debug/refresh", func() { _ = state.Update() })
951
+ }
952
+ `)
953
+ writeHTTPFixture(t, root, "cache/cache.go", `package cache
954
+ import "net/http"
955
+ type Cache struct{}
956
+ func New() *Cache { return &Cache{} }
957
+ func (*Cache) Update() error {
958
+ _, err := http.Get("https://geo.example/v1/airports")
959
+ return err
960
+ }
961
+ `)
962
+
963
+ resp, err := extract(plugin.Input{Root: root}, Options{Context: "ops", Service: "catalog"})
964
+ if err != nil {
965
+ t.Fatal(err)
966
+ }
967
+ var got catalog.Catalog
968
+ if err := json.Unmarshal([]byte(resp.Files[0].Contents), &got); err != nil {
969
+ t.Fatal(err)
970
+ }
971
+ if len(got.Flows) != 1 {
972
+ t.Fatalf("flows = %+v", got.Flows)
973
+ }
974
+ flow := got.Flows[0]
975
+ if flow.Name != "POST /debug/refresh → outbound APIs" || len(flow.Steps) != 2 {
976
+ t.Fatalf("rooted flow = %+v", flow)
977
+ }
978
+ if flow.Trigger == nil || flow.Trigger.Kind != "http" || flow.Trigger.Confidence != "high" {
979
+ t.Fatalf("trigger = %+v", flow.Trigger)
980
+ }
981
+ if step := flow.Steps[1].(*catalog.Step); step.Label != "GET /v1/airports" || len(step.Reaches) != 1 || step.Reaches[0] != "cache:Cache.Update" {
982
+ t.Fatalf("outbound step = %+v", step)
983
+ }
984
+ }
985
+
986
+ func TestBuildsAnnotatedCallbackRootAndCoversNestedHelper(t *testing.T) {
987
+ root := t.TempDir()
988
+ writeHTTPFixture(t, root, "go.mod", "module example.com/callback\n")
989
+ writeHTTPFixture(t, root, "callbacks/callback.go", `package callbacks
990
+ import "net/http"
991
+ type Registry struct{}
992
+ func (*Registry) Register(string, string, func()) {}
993
+ func Mount(registry *Registry) {
994
+ registry.Register("provider", "completed", Completed())
995
+ }
996
+ // Completed receives the provider webhook.
997
+ // @Router /callbacks/provider/completed [post]
998
+ func Completed() func() {
999
+ return func() { notifyCore() }
1000
+ }
1001
+ func notifyCore() { _, _ = http.Post("https://core.example/protected/update", "application/json", nil) }
1002
+ `)
1003
+
1004
+ resp, err := extract(plugin.Input{Root: root}, Options{Context: "travel", Service: "callbacks"})
1005
+ if err != nil {
1006
+ t.Fatal(err)
1007
+ }
1008
+ var got catalog.Catalog
1009
+ if err := json.Unmarshal([]byte(resp.Files[0].Contents), &got); err != nil {
1010
+ t.Fatal(err)
1011
+ }
1012
+ if len(got.Flows) != 1 {
1013
+ t.Fatalf("flows = %+v", got.Flows)
1014
+ }
1015
+ flow := got.Flows[0]
1016
+ if flow.Name != "POST /callbacks/provider/completed → outbound APIs" || len(flow.Steps) != 2 {
1017
+ t.Fatalf("callback flow = %+v", flow)
1018
+ }
1019
+ if flow.Trigger == nil || flow.Trigger.Kind != "callback" || flow.Trigger.Confidence != "high" {
1020
+ t.Fatalf("trigger = %+v", flow.Trigger)
1021
+ }
1022
+ if flow.Source != "callbacks/callback.go:6" {
1023
+ t.Fatalf("registration source = %q", flow.Source)
1024
+ }
1025
+ step := flow.Steps[1].(*catalog.Step)
1026
+ if step.Label != "POST /protected/update" || !strings.Contains(step.Note, "Completed → notifyCore") {
1027
+ t.Fatalf("callback continuation = %+v", step)
1028
+ }
1029
+ }
1030
+
1031
+ func TestBuildsStartupRootFromMainAssembly(t *testing.T) {
1032
+ root := t.TempDir()
1033
+ writeHTTPFixture(t, root, "go.mod", "module example.com/startup\n")
1034
+ writeHTTPFixture(t, root, "cmd/catalog/main.go", `package main
1035
+ import app "example.com/startup/internal/app/catalog"
1036
+ func main() { app.Run() }
1037
+ `)
1038
+ writeHTTPFixture(t, root, "internal/app/catalog/run.go", `package catalog
1039
+ import "example.com/startup/cache"
1040
+ func Run() {
1041
+ state := cache.New()
1042
+ if err := state.Warm(); err != nil { panic(err) }
1043
+ }
1044
+ `)
1045
+ writeHTTPFixture(t, root, "cache/cache.go", `package cache
1046
+ import "net/http"
1047
+ type Cache struct{}
1048
+ func New() *Cache { return &Cache{} }
1049
+ func (*Cache) Warm() error {
1050
+ _, err := http.Get("https://geo.example/v1/bootstrap")
1051
+ return err
1052
+ }
1053
+ `)
1054
+
1055
+ resp, err := extract(plugin.Input{Root: root}, Options{Context: "ops", Service: "catalog"})
1056
+ if err != nil {
1057
+ t.Fatal(err)
1058
+ }
1059
+ var got catalog.Catalog
1060
+ if err := json.Unmarshal([]byte(resp.Files[0].Contents), &got); err != nil {
1061
+ t.Fatal(err)
1062
+ }
1063
+ if len(got.Flows) != 1 {
1064
+ t.Fatalf("flows = %+v", got.Flows)
1065
+ }
1066
+ flow := got.Flows[0]
1067
+ if flow.Trigger == nil || flow.Trigger.Kind != "startup" || flow.Trigger.Confidence != "high" {
1068
+ t.Fatalf("trigger = %+v", flow.Trigger)
1069
+ }
1070
+ if flow.Name != "Startup → Cache.Warm → outbound APIs" || flow.Source != "internal/app/catalog/run.go:5" {
1071
+ t.Fatalf("startup flow = %+v", flow)
1072
+ }
1073
+ }
1074
+
1075
+ func TestBuildsScheduledRootFromTimerRegistration(t *testing.T) {
1076
+ root := t.TempDir()
1077
+ writeHTTPFixture(t, root, "go.mod", "module example.com/scheduled\n")
1078
+ writeHTTPFixture(t, root, "jobs/refresh.go", `package jobs
1079
+ import (
1080
+ "net/http"
1081
+ "time"
1082
+ )
1083
+ func Register() {
1084
+ time.AfterFunc(5*time.Minute, func() { Refresh() })
1085
+ }
1086
+ func Refresh() { _, _ = http.Get("https://catalog.example/v1/refresh") }
1087
+ `)
1088
+
1089
+ resp, err := extract(plugin.Input{Root: root}, Options{Context: "ops", Service: "catalog"})
1090
+ if err != nil {
1091
+ t.Fatal(err)
1092
+ }
1093
+ var got catalog.Catalog
1094
+ if err := json.Unmarshal([]byte(resp.Files[0].Contents), &got); err != nil {
1095
+ t.Fatal(err)
1096
+ }
1097
+ if len(got.Flows) != 1 {
1098
+ t.Fatalf("flows = %+v", got.Flows)
1099
+ }
1100
+ flow := got.Flows[0]
1101
+ if flow.Trigger == nil || flow.Trigger.Kind != "scheduled" || flow.Trigger.Confidence != "high" {
1102
+ t.Fatalf("trigger = %+v", flow.Trigger)
1103
+ }
1104
+ if !strings.Contains(flow.Name, "Schedule 5 * time.Minute → Refresh") || len(flow.Steps) != 2 {
1105
+ t.Fatalf("scheduled flow = %+v", flow)
1106
+ }
1107
+ }
1108
+
1109
+ func TestProviderInitIsBusinessOperationAndIncludesNestedHelper(t *testing.T) {
1110
+ root := t.TempDir()
1111
+ writeHTTPFixture(t, root, "go.mod", "module example.com/providerinit\n")
1112
+ writeHTTPFixture(t, root, "app/routes.go", `package app
1113
+ import "example.com/providerinit/connector"
1114
+ type Router struct{}
1115
+ func (*Router) POST(string, func()) {}
1116
+ func Routes(r *Router) { r.POST("/initialize", Initialize) }
1117
+ func Initialize() {
1118
+ conn := connector.Build()
1119
+ conn.Init()
1120
+ }
1121
+ `)
1122
+ writeHTTPFixture(t, root, "connector/factory.go", `package connector
1123
+ import "example.com/providerinit/provider"
1124
+ type API interface{ Init() }
1125
+ func Build() API { return provider.New() }
1126
+ `)
1127
+ writeHTTPFixture(t, root, "provider/provider.go", `package provider
1128
+ import "net/http"
1129
+ type Connector struct{}
1130
+ func New() *Connector { return &Connector{} }
1131
+ func (c *Connector) Init() { c.waitUntilReady() }
1132
+ func (*Connector) waitUntilReady() { _, _ = http.Get("https://provider.example/v1/status") }
1133
+ `)
1134
+
1135
+ resp, err := extract(plugin.Input{Root: root}, Options{Context: "travel", Service: "booking"})
1136
+ if err != nil {
1137
+ t.Fatal(err)
1138
+ }
1139
+ var got catalog.Catalog
1140
+ if err := json.Unmarshal([]byte(resp.Files[0].Contents), &got); err != nil {
1141
+ t.Fatal(err)
1142
+ }
1143
+ if len(got.Flows) != 1 {
1144
+ t.Fatalf("flows = %+v", got.Flows)
1145
+ }
1146
+ flow := got.Flows[0]
1147
+ if flow.Name != "POST /initialize → provider APIs" || len(flow.Steps) != 2 {
1148
+ t.Fatalf("endpoint flow = %+v", flow)
1149
+ }
1150
+ step := flow.Steps[1].(*catalog.Step)
1151
+ if step.Label != "GET /v1/status" || !strings.Contains(step.Note, "Connector.Init → Connector.waitUntilReady") {
1152
+ t.Fatalf("nested Init flow = %+v", step)
1153
+ }
1154
+ }
1155
+
1156
+ func TestDoesNotGuessBetweenMultipleConstructorWiredImplementations(t *testing.T) {
1157
+ root := t.TempDir()
1158
+ writeHTTPFixture(t, root, "go.mod", "module example.com/ambiguous\n")
1159
+ writeHTTPFixture(t, root, "port/api.go", `package port
1160
+ type API interface{ Cancel() }
1161
+ `)
1162
+ writeHTTPFixture(t, root, "connector/connector.go", `package connector
1163
+ import (
1164
+ "example.com/ambiguous/port"
1165
+ "example.com/ambiguous/provider/alpha"
1166
+ "example.com/ambiguous/provider/beta"
1167
+ )
1168
+ type Connector struct{ client port.API }
1169
+ func NewAlpha() *Connector { return &Connector{client: &alpha.Client{}} }
1170
+ func NewBeta() *Connector { return &Connector{client: &beta.Client{}} }
1171
+ func (c *Connector) Cancel() { c.client.Cancel() }
1172
+ `)
1173
+ writeHTTPFixture(t, root, "provider/alpha/client.go", `package alpha
1174
+ import "net/http"
1175
+ type Client struct{}
1176
+ func (*Client) Cancel() { _, _ = http.Get("https://alpha.example/cancel") }
1177
+ `)
1178
+ writeHTTPFixture(t, root, "provider/beta/client.go", `package beta
1179
+ import "net/http"
1180
+ type Client struct{}
1181
+ func (*Client) Cancel() { _, _ = http.Get("https://beta.example/cancel") }
1182
+ `)
1183
+
1184
+ resp, err := extract(plugin.Input{Root: root}, Options{Context: "travel", Service: "cancel"})
1185
+ if err != nil {
1186
+ t.Fatal(err)
1187
+ }
1188
+ var got catalog.Catalog
1189
+ if err := json.Unmarshal([]byte(resp.Files[0].Contents), &got); err != nil {
1190
+ t.Fatal(err)
1191
+ }
1192
+ if len(got.Flows) != 2 {
1193
+ t.Fatalf("ambiguous wiring created a composed flow: %+v", got.Flows)
1194
+ }
1195
+ for _, flow := range got.Flows {
1196
+ if flow.Name == "Connector Cancel → outbound APIs" {
1197
+ t.Fatalf("ambiguous receiver was guessed: %+v", flow)
1198
+ }
1199
+ }
1200
+ }
1201
+
1202
+ func TestCoveredEndpointCallRemovesWrapperWithoutAChainName(t *testing.T) {
1203
+ call := gohttp.Call{
1204
+ Function: "provider:Client.Send",
1205
+ ID: "http-client/POST /send",
1206
+ Source: gohttp.Source{File: "provider/client.go", Line: 42},
1207
+ }
1208
+ groups := []gohttp.FlowGroup{{Function: "provider:Connector.Publish", Calls: []gohttp.Call{call}}}
1209
+ endpoints := []gohttp.EndpointFlow{{Branches: []gohttp.EndpointBranch{{
1210
+ Function: "provider:Client.Send",
1211
+ Calls: []gohttp.Call{call},
1212
+ }}}}
1213
+ covered := map[string]bool{}
1214
+
1215
+ coverEndpointDescendants(covered, endpoints, groups)
1216
+
1217
+ if !covered["provider:Connector.Publish"] {
1218
+ t.Fatalf("exact source-backed wrapper was not covered: %+v", covered)
1219
+ }
1220
+ }
1221
+
1222
+ func TestOnlySourceBackedComplementsBecomeAnAlt(t *testing.T) {
1223
+ context := "edge"
1224
+ calls := []gohttp.Call{
1225
+ {Function: "choose", ID: "http-client/GET /one", Method: "GET", Path: "/one", Conditions: []string{"ready"}},
1226
+ {Function: "choose", ID: "http-client/GET /two", Method: "GET", Path: "/two", Conditions: []string{"not (ready)"}},
1227
+ }
1228
+ flows := flowsOf("edge.gateway", context, calls, Options{})
1229
+ if len(flows) != 1 || len(flows[0].Steps) != 1 {
1230
+ t.Fatalf("flows = %+v", flows)
1231
+ }
1232
+ alt, ok := flows[0].Steps[0].(*catalog.Alt)
1233
+ if !ok || alt.Branches[0].Title != "ready" || alt.Branches[1].Title != "not (ready)" {
1234
+ t.Fatalf("alt = %+v", flows[0].Steps[0])
1235
+ }
1236
+
1237
+ calls[1].Conditions = nil
1238
+ flows = flowsOf("edge.gateway", context, calls, Options{})
1239
+ if _, ok := flows[0].Steps[0].(*catalog.Alt); ok {
1240
+ t.Fatal("unrelated calls became an alternative")
1241
+ }
1242
+ }
1243
+
1244
+ func TestLearnsHandWrittenSOAPWrapperArgumentPositions(t *testing.T) {
1245
+ root := t.TempDir()
1246
+ writeHTTPFixture(t, root, "go.mod", "module example.com/checkout\n")
1247
+ writeHTTPFixture(t, root, "soap/booking.wsdl", `
1248
+ <definitions xmlns="http://schemas.xmlsoap.org/wsdl/" xmlns:soap="http://schemas.xmlsoap.org/wsdl/soap/" xmlns:tns="urn:booking" targetNamespace="urn:booking" name="Booking">
1249
+ <message name="CancelRequest"/><message name="CancelResponse"/>
1250
+ <portType name="BookingPortType"><operation name="Cancel"><input message="tns:CancelRequest"/><output message="tns:CancelResponse"/></operation></portType>
1251
+ <binding name="BookingBinding" type="tns:BookingPortType"><soap:binding style="document"/><operation name="Cancel"><soap:operation soapAction="urn:booking:cancel"/></operation></binding>
1252
+ <service name="BookingService"><port name="BookingPort" binding="tns:BookingBinding"><soap:address location="https://booking.example/soap"/></port></service>
1253
+ </definitions>`)
1254
+ writeHTTPFixture(t, root, "soap/transport.go", `package soap
1255
+ import "net/http"
1256
+ type Transport struct{}
1257
+ func (t *Transport) Call(actionBase, operation string, messageID string, query, reply any) error {
1258
+ req, err := http.NewRequest(http.MethodPost, "https://booking.example/soap", nil)
1259
+ if err != nil { return err }
1260
+ req.Header.Add("SOAPAction", actionBase+operation)
1261
+ return nil
1262
+ }
1263
+
1264
+ type Service struct{ transport *Transport }
1265
+ func (s *Service) Call(actionBase, operation string, query, reply any, stage string, options ...func()) error {
1266
+ return s.transport.Call(actionBase, operation, "message-id", query, reply)
1267
+ }`)
1268
+ writeHTTPFixture(t, root, "checkout/cancel.go", `package checkout
1269
+ import soapclient "example.com/checkout/soap"
1270
+ const actionBase = "urn:booking:"
1271
+ func CancelBooking(client *soapclient.Service, request, response any) error {
1272
+ return client.Call(actionBase, "cancel", request, response, "checkout")
1273
+ }`)
1274
+
1275
+ resp, err := extract(plugin.Input{Root: root}, Options{Context: "checkout", Service: "gateway"})
1276
+ if err != nil {
1277
+ t.Fatal(err)
1278
+ }
1279
+ var got catalog.Catalog
1280
+ if err := json.Unmarshal([]byte(resp.Files[0].Contents), &got); err != nil {
1281
+ t.Fatal(err)
1282
+ }
1283
+ service := got.Contexts[0].Services[0]
1284
+ if len(service.Consumes) != 1 {
1285
+ t.Fatalf("consumes = %+v", service.Consumes)
1286
+ }
1287
+ call := service.Consumes[0]
1288
+ if call.ID != "bookingservice.soap.BookingPort/Cancel" || call.Status != catalog.StatusDeclared || call.Source != "soap/booking.wsdl" {
1289
+ t.Fatalf("call = %+v", call)
1290
+ }
1291
+ if !strings.Contains(call.Note, "request 'request'") || !strings.Contains(call.Note, "response 'response'") {
1292
+ t.Fatalf("note = %q", call.Note)
1293
+ }
1294
+ if len(got.Flows) != 1 || got.Flows[0].Source != "checkout/cancel.go:4" {
1295
+ t.Fatalf("flows = %+v", got.Flows)
1296
+ }
1297
+ }
1298
+
1299
+ func TestReadsSOAP12ActionFromContentType(t *testing.T) {
1300
+ root := t.TempDir()
1301
+ writeHTTPFixture(t, root, "go.mod", "module example.com/soap12\n")
1302
+ writeHTTPFixture(t, root, "contract.wsdl", `
1303
+ <definitions xmlns="http://schemas.xmlsoap.org/wsdl/" xmlns:soap="http://schemas.xmlsoap.org/wsdl/soap/" xmlns:soap12="http://schemas.xmlsoap.org/wsdl/soap12/" xmlns:tns="urn:status" targetNamespace="urn:status" name="Status">
1304
+ <portType name="StatusPortType"><operation name="Check"/></portType>
1305
+ <binding name="Status11Binding" type="tns:StatusPortType"><soap:binding style="document"/><operation name="Check"><soap:operation soapAction="urn:status:check"/></operation></binding>
1306
+ <binding name="Status12Binding" type="tns:StatusPortType"><soap12:binding style="document"/><operation name="Check"><soap12:operation soapAction="urn:status:check"/></operation></binding>
1307
+ <service name="StatusService">
1308
+ <port name="Status11Port" binding="tns:Status11Binding"><soap:address location="https://status.example/soap11"/></port>
1309
+ <port name="Status12Port" binding="tns:Status12Binding"><soap12:address location="https://status.example/soap12"/></port>
1310
+ </service>
1311
+ </definitions>`)
1312
+ writeHTTPFixture(t, root, "transport.go", `package soap12
1313
+ import "net/http"
1314
+ type Client struct{}
1315
+ func (c *Client) Send(action string, request, response any) error {
1316
+ req, err := http.NewRequest(http.MethodPost, "https://status.example/soap", nil)
1317
+ if err != nil { return err }
1318
+ req.Header.Set("Content-Type", "application/soap+xml; charset=utf-8; action=\""+action+"\"")
1319
+ return nil
1320
+ }
1321
+ func Check(client *Client, request, response any) error {
1322
+ return client.Send("urn:status:check", request, response)
1323
+ }`)
1324
+
1325
+ resp, err := extract(plugin.Input{Root: root}, Options{Context: "monitoring", Service: "probe"})
1326
+ if err != nil {
1327
+ t.Fatal(err)
1328
+ }
1329
+ var got catalog.Catalog
1330
+ if err := json.Unmarshal([]byte(resp.Files[0].Contents), &got); err != nil {
1331
+ t.Fatal(err)
1332
+ }
1333
+ calls := got.Contexts[0].Services[0].Consumes
1334
+ if len(calls) != 1 || calls[0].ID != "statusservice.soap.Status12Port/Check" || calls[0].Status != catalog.StatusDeclared {
1335
+ t.Fatalf("calls = %+v", calls)
1336
+ }
1337
+ method := got.Externals[0].Provides[0].Methods[0]
1338
+ if method.SOAP == nil || method.SOAP.Version != "1.2" || method.SOAP.Action != "urn:status:check" {
1339
+ t.Fatalf("method = %+v", method)
1340
+ }
1341
+ }
1342
+
1343
+ func TestDestinationThroughFunctionalOption(t *testing.T) {
1344
+ for _, ambiguous := range []bool{false, true} {
1345
+ t.Run(fmt.Sprint(ambiguous), func(t *testing.T) {
1346
+ root := t.TempDir()
1347
+ writeHTTPFixture(t, root, "go.mod", "module example.com/destination\n")
1348
+ writeHTTPFixture(t, root, "client.go", `package destination
1349
+ import "net/http"
1350
+ type Config struct { SettingAddr string `+"`envconfig:\"SETTINGS_ADDR\" default:\"http://localhost:8000/settings\"`"+` }
1351
+ type Client struct { baseURL string }
1352
+ type Option func(*Client)
1353
+ func WithBaseURL(value string) Option { return func(c *Client) { c.baseURL = value } }
1354
+ func NewClient(options ...Option) *Client {
1355
+ c := &Client{}
1356
+ for _, opt := range options { opt(c) }
1357
+ return c
1358
+ }
1359
+ func runtimeURL(path string) string { return path }
1360
+ func (c *Client) request(method, path string) { path = c.baseURL + path; path = runtimeURL(path); http.NewRequest(method, path, nil) }
1361
+ func (c *Client) POST(path string) { c.request(http.MethodPost, path) }
1362
+ type Manager struct { client *Client }
1363
+ func New(client *Client) *Manager { return &Manager{client: client} }
1364
+ func Build(cfg Config) *Manager { return New(NewClient(WithBaseURL(cfg.SettingAddr))) }
1365
+ func (m *Manager) Settings() { m.client.POST("/get-admin-settings") }
1366
+ type Other struct { client *Client }
1367
+ func NewOther(client *Client) *Other { return &Other{client: client} }
1368
+ func BuildOther() *Other { return NewOther(NewClient(WithBaseURL("http://other/billing"))) }
1369
+ func (m *Other) Settings() { m.client.POST("/get-admin-settings") }
1370
+ `)
1371
+ if ambiguous {
1372
+ writeHTTPFixture(t, root, "other.go", `package destination
1373
+ func Unknown(value string) *Manager { return New(NewClient(WithBaseURL(value))) }
1374
+ `)
1375
+ }
1376
+ resp, err := extract(plugin.Input{Root: root}, Options{Context: "avia", Service: "aviacore"})
1377
+ if err != nil {
1378
+ t.Fatal(err)
1379
+ }
1380
+ var got catalog.Catalog
1381
+ if err := json.Unmarshal([]byte(resp.Files[0].Contents), &got); err != nil {
1382
+ t.Fatal(err)
1383
+ }
1384
+ found := false
1385
+ for _, flow := range got.Flows {
1386
+ if flow.EntryPoint == "Other.Settings" {
1387
+ step := flow.Steps[0].(*catalog.Step)
1388
+ if d := step.Destination; d == nil || d.FullPath != "/billing/get-admin-settings" || d.ServiceDiscoveryAlias != "other" {
1389
+ t.Fatalf("other client destination = %+v", d)
1390
+ }
1391
+ }
1392
+ if flow.EntryPoint != "Manager.Settings" {
1393
+ continue
1394
+ }
1395
+ for _, node := range flow.Steps {
1396
+ step, ok := node.(*catalog.Step)
1397
+ if !ok || step.Kind != catalog.StepRPC {
1398
+ continue
1399
+ }
1400
+ found = true
1401
+ d := step.Destination
1402
+ if d == nil {
1403
+ t.Fatalf("missing destination: %+v", step)
1404
+ }
1405
+ if ambiguous {
1406
+ if d.FullPath != "" {
1407
+ t.Fatalf("ambiguous destination resolved: %+v", d)
1408
+ }
1409
+ continue
1410
+ }
1411
+ if d.FullPath != "/settings/get-admin-settings" || d.LocalPath != "/get-admin-settings" || d.BaseURL == nil || d.BaseURL.ConfigField != "Config.SettingAddr" || d.BaseURL.EnvironmentVariable != "SETTINGS_ADDR" || d.BaseURL.Kind != "config-default" || d.Join == nil || d.Join.Expression != "c.baseURL + path" || len(d.Transforms) != 1 || d.Transforms[0].Expression != "runtimeURL(path)" {
1412
+ t.Fatalf("destination = %+v; base = %+v", d, d.BaseURL)
1413
+ }
1414
+ }
1415
+ }
1416
+ if !found {
1417
+ t.Fatalf("no Manager.Settings flow: %+v", got.Flows)
1418
+ }
1419
+ })
1420
+ }
1421
+ }
1422
+
1423
+ // PORTOLAN-19: the verb and path are not a call identity. One shared request
1424
+ // helper addressed to two hosts is two dependencies, and neither the analyzer's
1425
+ // call-site deduplication nor the consumer list may glue them together.
1426
+ func TestSameRouteToDifferentHostsStaysTwoCalls(t *testing.T) {
1427
+ root := t.TempDir()
1428
+ writeHTTPFixture(t, root, "go.mod", "module example.com/clients\n")
1429
+ writeHTTPFixture(t, root, "client.go", `package clients
1430
+ import "net/http"
1431
+ type Client struct { baseURL string }
1432
+ type Option func(*Client)
1433
+ func WithBaseURL(value string) Option { return func(c *Client) { c.baseURL = value } }
1434
+ func NewClient(options ...Option) *Client {
1435
+ c := &Client{}
1436
+ for _, opt := range options { opt(c) }
1437
+ return c
1438
+ }
1439
+ func (c *Client) request(method, path string) { path = c.baseURL + path; http.NewRequest(method, path, nil) }
1440
+ func (c *Client) post(path string) { c.request(http.MethodPost, path) }
1441
+ type Payments struct { client *Client }
1442
+ func NewPayments(client *Client) *Payments { return &Payments{client: client} }
1443
+ func BuildPayments() *Payments { return NewPayments(NewClient(WithBaseURL("https://payments.internal"))) }
1444
+ func (p *Payments) Settle() { p.client.post("/foo") }
1445
+ type Ledger struct { client *Client }
1446
+ func NewLedger(client *Client) *Ledger { return &Ledger{client: client} }
1447
+ func BuildLedger() *Ledger { return NewLedger(NewClient(WithBaseURL("https://ledger.internal"))) }
1448
+ func (l *Ledger) Record() { l.client.post("/foo") }
1449
+ `)
1450
+ resp, err := extract(plugin.Input{Root: root}, Options{Context: "shop", Service: "checkout"})
1451
+ if err != nil {
1452
+ t.Fatal(err)
1453
+ }
1454
+ var got catalog.Catalog
1455
+ if err := json.Unmarshal([]byte(resp.Files[0].Contents), &got); err != nil {
1456
+ t.Fatal(err)
1457
+ }
1458
+ consumes := got.Contexts[0].Services[0].Consumes
1459
+ if len(consumes) != 2 {
1460
+ t.Fatalf("consumes = %+v", consumes)
1461
+ }
1462
+ byID := map[string]catalog.RpcCall{}
1463
+ for _, call := range consumes {
1464
+ byID[call.ID] = call
1465
+ }
1466
+ for host, peer := range map[string]string{"ledger.internal": "ledger-internal", "payments.internal": "payments-internal"} {
1467
+ call, ok := byID["http-client/POST /foo @ "+host]
1468
+ if !ok {
1469
+ t.Fatalf("no consume for %s in %+v", host, consumes)
1470
+ }
1471
+ if call.Peer != peer || call.Status != catalog.StatusUnresolved || call.Destination == nil || call.Destination.ServiceDiscoveryAlias != host || call.Destination.FullPath != "/foo" {
1472
+ t.Fatalf("%s = %+v (destination %+v)", host, call, call.Destination)
1473
+ }
1474
+ }
1475
+ refs := map[string]string{}
1476
+ for _, flow := range got.Flows {
1477
+ for _, node := range flow.Steps {
1478
+ if step, ok := node.(*catalog.Step); ok && step.Kind == catalog.StepRPC {
1479
+ refs[flow.EntryPoint] = step.Ref
1480
+ }
1481
+ }
1482
+ }
1483
+ if refs["Payments.Settle"] != "http-client/POST /foo @ payments.internal" || refs["Ledger.Record"] != "http-client/POST /foo @ ledger.internal" {
1484
+ t.Fatalf("flow refs = %+v", refs)
1485
+ }
1486
+ }
1487
+
1488
+ // Several call sites addressed to one destination are one dependency: the entry
1489
+ // keeps the first site in source order and every site as evidence, and the
1490
+ // order files are visited in cannot change which entries exist.
1491
+ func TestCallSitesToOneDestinationAggregateWithProvenance(t *testing.T) {
1492
+ settle := `package clients
1493
+ import "net/http"
1494
+ func Settle() { http.Post("https://payments.internal/foo", "application/json", nil) }
1495
+ `
1496
+ retry := `package clients
1497
+ import "net/http"
1498
+ func Retry() { http.Post("https://payments.internal/foo", "application/json", nil) }
1499
+ `
1500
+ record := `package clients
1501
+ import "net/http"
1502
+ func Record() { http.Post("https://ledger.internal/foo", "application/json", nil) }
1503
+ `
1504
+ run := func(files map[string]string) []catalog.RpcCall {
1505
+ root := t.TempDir()
1506
+ writeHTTPFixture(t, root, "go.mod", "module example.com/clients\n")
1507
+ for name, contents := range files {
1508
+ writeHTTPFixture(t, root, name, contents)
1509
+ }
1510
+ resp, err := extract(plugin.Input{Root: root}, Options{Context: "shop", Service: "checkout"})
1511
+ if err != nil {
1512
+ t.Fatal(err)
1513
+ }
1514
+ var got catalog.Catalog
1515
+ if err := json.Unmarshal([]byte(resp.Files[0].Contents), &got); err != nil {
1516
+ t.Fatal(err)
1517
+ }
1518
+ return got.Contexts[0].Services[0].Consumes
1519
+ }
1520
+ consumes := run(map[string]string{"a_settle.go": settle, "b_retry.go": retry, "c_record.go": record})
1521
+ if len(consumes) != 2 {
1522
+ t.Fatalf("consumes = %+v", consumes)
1523
+ }
1524
+ var payments *catalog.RpcCall
1525
+ for i := range consumes {
1526
+ if consumes[i].ID == "http-client/POST /foo @ payments.internal" {
1527
+ payments = &consumes[i]
1528
+ }
1529
+ }
1530
+ if payments == nil || payments.Source != "a_settle.go:3" {
1531
+ t.Fatalf("payments = %+v", payments)
1532
+ }
1533
+ var sites []string
1534
+ for _, item := range payments.Evidence {
1535
+ if item.Kind == "call-site" {
1536
+ sites = append(sites, item.Source)
1537
+ }
1538
+ }
1539
+ if strings.Join(sites, ",") != "a_settle.go:3,b_retry.go:3" {
1540
+ t.Fatalf("call sites = %v", sites)
1541
+ }
1542
+
1543
+ // Reverse the file order: the same identities come out, only the first
1544
+ // site (and with it the entry's source) follows the new order.
1545
+ reversed := run(map[string]string{"c_settle.go": settle, "b_retry.go": retry, "a_record.go": record})
1546
+ ids := func(calls []catalog.RpcCall) []string {
1547
+ var out []string
1548
+ for _, call := range calls {
1549
+ out = append(out, call.ID+" -> "+call.Peer)
1550
+ }
1551
+ return out
1552
+ }
1553
+ if strings.Join(ids(consumes), "\n") != strings.Join(ids(reversed), "\n") {
1554
+ t.Fatalf("file order changed the result:\n%v\n%v", ids(consumes), ids(reversed))
1555
+ }
1556
+ for _, call := range reversed {
1557
+ if call.ID == "http-client/POST /foo @ payments.internal" && call.Source != "b_retry.go:3" {
1558
+ t.Fatalf("reversed payments source = %q", call.Source)
1559
+ }
1560
+ }
1561
+ }