Skip to content

Commit fe6e105

Browse files
authored
Recover hardcoded host in Go HTTP-client routes (v101) (#90)
A `baseURL + "/path"` client call discarded the host, so third-party API calls were counted as unresolved internal edges and flipped an isolated backend to a false coverage_gap. Resolve the base identifier through a package-scoped string-literal index and tag external/host when every binding is an absolute http(s) URL. Also match a client route against the server index before bucketing it external, so a route tagged external that targets a loaded internal host still resolves to its cross-repo edge. Bumps cacheVersion to v101; adds the go_httpclient_multirepo golden.
1 parent 099496d commit fe6e105

13 files changed

Lines changed: 531 additions & 57 deletions

File tree

internal/cachecov/coverage_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ var versionCoverage = map[int][]string{
132132
98: {"TestKtComplexity_ScalingLoopDepth_ConstantRangeDiscounted", "TestKtComplexity_ScalingLoopDepth_VariableRangeNotDiscounted", "TestKtComplexity_ScalingLoopDepth_ConstantIteratorReceiverDiscounted", "TestKtComplexity_ScalingLoopDepth_VariableIteratorReceiverNotDiscounted", "TestKtComplexity_ScalingLoopDepth_ConstantOuterScalingInner", "TestKtComplexity_ScalingLoopDepth_ConstantInnerScalingOuter", "TestKtComplexity_ScalingLoopDepth_InfiniteLoopDiscounted", "TestKtComplexity_ScalingLoopDepth_ConditionalWhileNotDiscounted", "TestKtComplexity_ScalingLoopDepth_AbsentWithoutLoops", "TestKtComplexity_LoopCountAndCyclomaticUnchangedByBounding", "TestGolden"}, // Kotlin bounded-loop discounting: scaling_loop_depth joins the Go/Python/TS convention
133133
99: {"TestKtComplexity_CallsInScalingLoop_ConstantExcluded", "TestKtComplexity_CallsInScalingLoop_InfiniteLoopCallsRetained", "TestKtComplexity_CallsInScalingLoop_ScalingRetained", "TestKtComplexity_CallsInScalingLoop_MixedLoops", "TestKtComplexity_CallsInScalingLoop_AbsentWithoutLoopCalls", "TestExtract_CallsInScalingLoop_InfiniteLoopCallsRetained", "TestExtract_CallsInScalingLoop_PresentButEmptyWhenAllBounded", "TestExtract_CallsInScalingLoop_AbsentWithoutLoopCalls", "TestPyComplexity_CallsInScalingLoop_InfiniteLoopCallsRetained", "TestPyComplexity_CallsInScalingLoop_PresentButEmptyWhenAllBounded", "TestPyComplexity_CallsInScalingLoop_AbsentWithoutLoopCalls", "TestTsComplexity_CallsInScalingLoop_InfiniteLoopCallsRetained", "TestTsComplexity_CallsInScalingLoop_PresentButEmptyWhenAllBounded", "TestTsComplexity_CallsInScalingLoop_AbsentWithoutLoopCalls", "TestGolden"}, // calls_in_scaling_loop = repeated (not merely scaling) loops, emitted even when empty
134134
100: {"TestExtractTestRefs_InPackageCallResolvesToProductionSymbol", "TestExtractTestRefs_ExternalTestPackageResolvesThroughImport", "TestExtractTestRefs_SkipsBuiltinsAndEmitsNoSymbols", "TestExtractTestRefs_IgnoresFilesItDoesNotOwn", "TestExtractTestRefs_ReferenceFreeFileYieldsNoFact", "TestDefaultTestGlobsCoverGoAndStayIgnored", "TestGolden"}, // Go test_ref facts: both gates, so a function called only from its _test.go is no longer high-confidence dead
135+
101: {"TestGoHTTPClient_PackageConstAbsoluteBaseURL_TaggedExternalWithHost", "TestGoHTTPClient_PackageScopedConstFromSiblingFile_TaggedExternal", "TestGoHTTPClient_StructFieldAbsoluteBaseURL_TaggedExternalNoHostWhenAmbiguous", "TestGoHTTPClient_ConfigInjectedBaseURL_NotTaggedExternal", "TestGoHTTPClient_RelativeLiteralBaseURL_NotTaggedExternal", "TestGoHTTPClient_MixedAbsoluteAndRelativeBindings_NotTaggedExternal", "TestGoHTTPClient_RouteNameUnchangedByExternalTagging", "TestComputeLinks_ExternalClientStillMatchesLoadedServer", "TestGolden"}, // Go base-URL host recovery: third-party client calls tagged external, hardcoded internal host still resolves
135136
}
136137

137138
func TestCacheVersionCoverage(t *testing.T) {

internal/engine/cache.go

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -296,7 +296,19 @@ import (
296296
// spelled exactly as one from production and inherits goBuiltins filtering: a package-level
297297
// `min` shadowing the Go 1.21 builtin is still not credited, from either side. The file set
298298
// reaching the extractors changes, so cached snapshots must re-extract.
299-
const cacheVersion = "v100"
299+
// v101: the Go HTTP-client extractor recovers the host that `baseURL + "/path"`
300+
// concatenation discards. It resolves the base identifier through a package-scoped
301+
// string-literal index (const/var/assignment/composite-field bindings) and, when
302+
// every binding is an absolute http(s) URL, tags the route external=true (plus a
303+
// host prop when the bindings agree on one host). Before this, a service calling
304+
// only third-party APIs — golf, calling ZeptoMail and MailerLite via base-URL
305+
// concats — accumulated phantom "unresolved internal edges" and was misclassified
306+
// coverage_gap. A config-injected base (options.BaseURL, no literal binding) stays
307+
// an internal client route. Route facts gain external/host props, so cached Go
308+
// snapshots must re-extract. (The paired linker change — matching a client route
309+
// before bucketing it external, so a hardcoded internal host still resolves — runs
310+
// post-extraction and needs no cache bump; it is covered by TestGolden here.)
311+
const cacheVersion = "v101"
300312

301313
// extractorCache holds per-extractor facts keyed by a content hash of the files
302314
// the extractor depends on. It is loaded from disk at the start of a snapshot and

internal/engine/golden_test.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,11 @@ var fixtures = []fixture{
5858
{name: "multirepo", subRepos: []string{"repoA", "repoB"}},
5959
{name: "php_multirepo", subRepos: []string{"provider", "consumer"}},
6060
{name: "go_grpc_multirepo", subRepos: []string{"server", "client"}},
61+
// A Go backend plus a Go client that calls it and two third-party APIs. Pins
62+
// GAP-LK-02 (v101): a `baseURL + "/path"` concat to a hardcoded host is tagged
63+
// external, a hardcoded INTERNAL host still resolves to its loaded repo, and a
64+
// config-injected base URL stays an unresolved internal edge.
65+
{name: "go_httpclient_multirepo", subRepos: []string{"api", "consumer"}},
6166
{name: "py_grpc_multirepo", subRepos: []string{"server", "client"}},
6267
// Two different-language repos sharing only nested type names. The linker must
6368
// draw no shared_symbols edge between them; see GAP-LK-03.
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{"kind":"dependency","name":". -\u003e context","file":"consumer/client.go","line":4,"repo":"consumer","props":{"language":"go","source":"stdlib"},"relations":[{"kind":"imports","target":"context"}]}
2+
{"kind":"dependency","name":". -\u003e github.com/gorilla/mux","file":"api/server.go","line":6,"repo":"api","props":{"language":"go","source":"external"},"relations":[{"kind":"imports","target":"github.com/gorilla/mux"}]}
3+
{"kind":"dependency","name":". -\u003e net/http","file":"api/server.go","line":4,"repo":"api","props":{"language":"go","source":"stdlib"},"relations":[{"kind":"imports","target":"net/http"}]}
4+
{"kind":"dependency","name":". -\u003e net/http","file":"consumer/client.go","line":5,"repo":"consumer","props":{"language":"go","source":"stdlib"},"relations":[{"kind":"imports","target":"net/http"}]}
5+
{"kind":"dependency","name":"consumer -\u003e api","repo":"consumer","props":{"confidence":"probable","endpoint_count":1,"endpoints":["GET /v1/things/{id}"],"synthetic":"crossrepo","type":"cross_repo","via":["http-client"]}}
6+
{"kind":"module","name":".","file":"api/.","repo":"api","props":{"language":"go","modulePath":"example.com/api","package":"main"}}
7+
{"kind":"module","name":".","file":"consumer/.","repo":"consumer","props":{"language":"go","modulePath":"example.com/consumer","package":"main"}}
8+
{"kind":"route","name":"/v1/internal","file":"consumer/client.go","line":44,"repo":"consumer","props":{"api":"client","framework":"net-http","language":"go","method":"GET","role":"client","source":"go-http-client","target_hint":"","unmatched_by_server":true,"unmatched_reason":"path_unknown"},"relations":[{"kind":"declares","target":"."}]}
9+
{"kind":"route","name":"/v1/things/{id}","file":"api/server.go","line":13,"repo":"api","props":{"framework":"gorilla/mux","handler":"getThing","language":"go","method":"GET"},"relations":[{"kind":"declares","target":"."}]}
10+
{"kind":"route","name":"/v1/things/{id}","file":"consumer/client.go","line":42,"repo":"consumer","props":{"api":"client","external":true,"framework":"net-http","host":"api:8080","language":"go","method":"GET","role":"client","source":"go-http-client","target_hint":""},"relations":[{"kind":"declares","target":"."}]}
11+
{"kind":"route","name":"/v1/widgets","file":"consumer/client.go","line":41,"repo":"consumer","props":{"api":"client","external":true,"framework":"net-http","host":"api.example.com","language":"go","method":"GET","role":"client","source":"go-http-client","target_hint":""},"relations":[{"kind":"declares","target":"."}]}
12+
{"kind":"route","name":"/v3/messages","file":"consumer/client.go","line":43,"repo":"consumer","props":{"api":"client","external":true,"framework":"net-http","language":"go","method":"POST","role":"client","source":"go-http-client","target_hint":""},"relations":[{"kind":"declares","target":"."}]}
13+
{"kind":"service","name":"api","repo":"api","props":{"synthetic":"crossrepo"}}
14+
{"kind":"service","name":"consumer","repo":"consumer","props":{"edge_coverage":[{"detected":4,"edge_type":"http_client","external":2,"resolved":1,"unresolved":1}],"synthetic":"crossrepo"},"relations":[{"kind":"depends_on","target":"api"}]}
15+
{"kind":"symbol","name":"..Client","file":"consumer/client.go","line":35,"repo":"consumer","props":{"exported":true,"language":"go","symbol_kind":"struct"},"relations":[{"kind":"declares","target":"."}]}
16+
{"kind":"symbol","name":"..Client.run","file":"consumer/client.go","line":40,"repo":"consumer","props":{"cyclomatic":1,"exported":false,"language":"go","receiver":"Client","symbol_kind":"method"},"relations":[{"kind":"calls","target":"net/http.Get"},{"kind":"calls","target":"net/http.NewRequest"},{"kind":"calls","target":"net/http.NewRequestWithContext"},{"kind":"declares","target":"."}]}
17+
{"kind":"symbol","name":"..NewZepto","file":"consumer/client.go","line":20,"repo":"consumer","props":{"cyclomatic":2,"exported":true,"language":"go","symbol_kind":"function"},"relations":[{"kind":"declares","target":"."}]}
18+
{"kind":"symbol","name":"..Options","file":"consumer/client.go","line":33,"repo":"consumer","props":{"exported":true,"language":"go","symbol_kind":"struct"},"relations":[{"kind":"declares","target":"."}]}
19+
{"kind":"symbol","name":"..Zepto","file":"consumer/client.go","line":18,"repo":"consumer","props":{"exported":true,"language":"go","symbol_kind":"struct"},"relations":[{"kind":"declares","target":"."}]}
20+
{"kind":"symbol","name":"..getThing","file":"api/server.go","line":17,"repo":"api","props":{"cyclomatic":1,"exported":false,"language":"go","symbol_kind":"function"},"relations":[{"kind":"declares","target":"."}]}
21+
{"kind":"symbol","name":"..main","file":"api/server.go","line":11,"repo":"api","props":{"cyclomatic":1,"exported":false,"language":"go","symbol_kind":"function"},"relations":[{"kind":"calls","target":"github.com/gorilla/mux.NewRouter"},{"kind":"calls","target":"github.com/gorilla/mux.Router.HandleFunc"},{"kind":"calls","target":"net/http.ListenAndServe"},{"kind":"declares","target":"."}]}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
module example.com/api
2+
3+
go 1.25
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package main
2+
3+
import (
4+
"net/http"
5+
6+
"github.com/gorilla/mux"
7+
)
8+
9+
// The api service serves one route; the consumer's hardcoded internal-host call
10+
// must resolve to it even though that call is also tagged external.
11+
func main() {
12+
r := mux.NewRouter()
13+
r.HandleFunc("/v1/things/{id}", getThing).Methods("GET")
14+
http.ListenAndServe(":8080", r)
15+
}
16+
17+
func getThing(w http.ResponseWriter, req *http.Request) {}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"net/http"
6+
)
7+
8+
// extBase is a third-party host with no loaded server: tagged external, host known.
9+
const extBase = "https://api.example.com"
10+
11+
// internalBase is a hardcoded INTERNAL host that IS a loaded repo. The call to it
12+
// must resolve to the api service AND be tagged external — the linker attempts the
13+
// server match before falling back to the external bucket (GAP-LK-02, v101).
14+
const internalBase = "http://api:8080"
15+
16+
// Zepto mirrors golf's region-switch idiom: a struct field bound to several
17+
// absolute literals that disagree on host — external, but no single host.
18+
type Zepto struct{ baseURL string }
19+
20+
func NewZepto(region string) *Zepto {
21+
var baseURL string
22+
switch region {
23+
case "eu":
24+
baseURL = "https://api.zeptomail.eu/v1.1"
25+
default:
26+
baseURL = "https://api.zeptomail.com/v1.1"
27+
}
28+
return &Zepto{baseURL: baseURL}
29+
}
30+
31+
// Options is injected from config; BaseURL has no string-literal binding, so the
32+
// call below stays an internal client route and remains an unresolved edge.
33+
type Options struct{ BaseURL string }
34+
35+
type Client struct {
36+
zepto *Zepto
37+
options Options
38+
}
39+
40+
func (c *Client) run(ctx context.Context) {
41+
http.Get(extBase + "/v1/widgets")
42+
http.NewRequestWithContext(ctx, "GET", internalBase+"/v1/things/{id}", nil)
43+
http.NewRequest("POST", c.zepto.baseURL+"/v3/messages", nil)
44+
http.Get(c.options.BaseURL + "/v1/internal")
45+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
module example.com/consumer
2+
3+
go 1.25

internal/extractors/goextractor/go.go

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -162,12 +162,17 @@ func (e *GoExtractor) extractPackage(fset *token.FileSet, pkgDir string, pp *par
162162
// resolves at its call sites.
163163
pkgVarClients := collectPackageVarClients(pp.parsedFiles, grpcStubs)
164164

165+
// Package-scoped string-literal bindings (const/var/assign/field) so a
166+
// `baseURL + "/path"` client call can recover the host the concat discards,
167+
// even when the base URL is declared in a sibling file of the same package.
168+
baseURLLits := collectBaseURLLiterals(pp.parsedFiles)
169+
165170
for _, relFile := range pp.relFiles {
166171
f, ok := pp.fileMap[relFile]
167172
if !ok {
168173
continue
169174
}
170-
result = append(result, e.extractFile(fset, f, relFile, pkgDir, modulePath, fieldTypes, pkgNames, grpcStubs, pkgVarClients)...)
175+
result = append(result, e.extractFile(fset, f, relFile, pkgDir, modulePath, fieldTypes, pkgNames, grpcStubs, pkgVarClients, baseURLLits)...)
171176
}
172177

173178
moduleFact := facts.Fact{
@@ -189,7 +194,7 @@ func (e *GoExtractor) extractPackage(fset *token.FileSet, pkgDir string, pp *par
189194
return result
190195
}
191196

192-
func (e *GoExtractor) extractFile(fset *token.FileSet, f *ast.File, relFile, pkgDir, modulePath string, fieldTypes map[string]string, pkgNames map[string]string, grpcStubs *goGRPCStubIndex, pkgVarClients map[string]string) []facts.Fact {
197+
func (e *GoExtractor) extractFile(fset *token.FileSet, f *ast.File, relFile, pkgDir, modulePath string, fieldTypes map[string]string, pkgNames map[string]string, grpcStubs *goGRPCStubIndex, pkgVarClients map[string]string, baseURLLits map[string][]string) []facts.Fact {
193198
var result []facts.Fact
194199

195200
// Build per-file import alias map for call resolution.
@@ -240,7 +245,7 @@ func (e *GoExtractor) extractFile(fset *token.FileSet, f *ast.File, relFile, pkg
240245
result = append(result, extractRoutes(fset, f, relFile, pkgDir)...)
241246

242247
// Extract outbound HTTP-client calls
243-
result = append(result, extractHTTPClientFacts(fset, f, relFile, pkgDir)...)
248+
result = append(result, extractHTTPClientFacts(fset, f, relFile, pkgDir, baseURLLits)...)
244249

245250
// Extract outbound gRPC-client calls
246251
result = append(result, extractGRPCClientFacts(fset, f, relFile, pkgDir, modulePath, fileImports, fieldTypes, pkgVarClients, grpcStubs)...)

0 commit comments

Comments
 (0)