Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions internal/cachecov/coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ var versionCoverage = map[int][]string{
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
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
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
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
}

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

// extractorCache holds per-extractor facts keyed by a content hash of the files
// the extractor depends on. It is loaded from disk at the start of a snapshot and
Expand Down
5 changes: 5 additions & 0 deletions internal/engine/golden_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@ var fixtures = []fixture{
{name: "multirepo", subRepos: []string{"repoA", "repoB"}},
{name: "php_multirepo", subRepos: []string{"provider", "consumer"}},
{name: "go_grpc_multirepo", subRepos: []string{"server", "client"}},
// A Go backend plus a Go client that calls it and two third-party APIs. Pins
// GAP-LK-02 (v101): a `baseURL + "/path"` concat to a hardcoded host is tagged
// external, a hardcoded INTERNAL host still resolves to its loaded repo, and a
// config-injected base URL stays an unresolved internal edge.
{name: "go_httpclient_multirepo", subRepos: []string{"api", "consumer"}},
{name: "py_grpc_multirepo", subRepos: []string{"server", "client"}},
// Two different-language repos sharing only nested type names. The linker must
// draw no shared_symbols edge between them; see GAP-LK-03.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{"kind":"dependency","name":". -\u003e context","file":"consumer/client.go","line":4,"repo":"consumer","props":{"language":"go","source":"stdlib"},"relations":[{"kind":"imports","target":"context"}]}
{"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"}]}
{"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"}]}
{"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"}]}
{"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"]}}
{"kind":"module","name":".","file":"api/.","repo":"api","props":{"language":"go","modulePath":"example.com/api","package":"main"}}
{"kind":"module","name":".","file":"consumer/.","repo":"consumer","props":{"language":"go","modulePath":"example.com/consumer","package":"main"}}
{"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":"."}]}
{"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":"."}]}
{"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":"."}]}
{"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":"."}]}
{"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":"."}]}
{"kind":"service","name":"api","repo":"api","props":{"synthetic":"crossrepo"}}
{"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"}]}
{"kind":"symbol","name":"..Client","file":"consumer/client.go","line":35,"repo":"consumer","props":{"exported":true,"language":"go","symbol_kind":"struct"},"relations":[{"kind":"declares","target":"."}]}
{"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":"."}]}
{"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":"."}]}
{"kind":"symbol","name":"..Options","file":"consumer/client.go","line":33,"repo":"consumer","props":{"exported":true,"language":"go","symbol_kind":"struct"},"relations":[{"kind":"declares","target":"."}]}
{"kind":"symbol","name":"..Zepto","file":"consumer/client.go","line":18,"repo":"consumer","props":{"exported":true,"language":"go","symbol_kind":"struct"},"relations":[{"kind":"declares","target":"."}]}
{"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":"."}]}
{"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":"."}]}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module example.com/api

go 1.25
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package main

import (
"net/http"

"github.com/gorilla/mux"
)

// The api service serves one route; the consumer's hardcoded internal-host call
// must resolve to it even though that call is also tagged external.
func main() {
r := mux.NewRouter()
r.HandleFunc("/v1/things/{id}", getThing).Methods("GET")
http.ListenAndServe(":8080", r)
}

func getThing(w http.ResponseWriter, req *http.Request) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package main

import (
"context"
"net/http"
)

// extBase is a third-party host with no loaded server: tagged external, host known.
const extBase = "https://api.example.com"

// internalBase is a hardcoded INTERNAL host that IS a loaded repo. The call to it
// must resolve to the api service AND be tagged external — the linker attempts the
// server match before falling back to the external bucket (GAP-LK-02, v101).
const internalBase = "http://api:8080"

// Zepto mirrors golf's region-switch idiom: a struct field bound to several
// absolute literals that disagree on host — external, but no single host.
type Zepto struct{ baseURL string }

func NewZepto(region string) *Zepto {
var baseURL string
switch region {
case "eu":
baseURL = "https://api.zeptomail.eu/v1.1"
default:
baseURL = "https://api.zeptomail.com/v1.1"
}
return &Zepto{baseURL: baseURL}
}

// Options is injected from config; BaseURL has no string-literal binding, so the
// call below stays an internal client route and remains an unresolved edge.
type Options struct{ BaseURL string }

type Client struct {
zepto *Zepto
options Options
}

func (c *Client) run(ctx context.Context) {
http.Get(extBase + "/v1/widgets")
http.NewRequestWithContext(ctx, "GET", internalBase+"/v1/things/{id}", nil)
http.NewRequest("POST", c.zepto.baseURL+"/v3/messages", nil)
http.Get(c.options.BaseURL + "/v1/internal")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module example.com/consumer

go 1.25
11 changes: 8 additions & 3 deletions internal/extractors/goextractor/go.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,12 +162,17 @@ func (e *GoExtractor) extractPackage(fset *token.FileSet, pkgDir string, pp *par
// resolves at its call sites.
pkgVarClients := collectPackageVarClients(pp.parsedFiles, grpcStubs)

// Package-scoped string-literal bindings (const/var/assign/field) so a
// `baseURL + "/path"` client call can recover the host the concat discards,
// even when the base URL is declared in a sibling file of the same package.
baseURLLits := collectBaseURLLiterals(pp.parsedFiles)

for _, relFile := range pp.relFiles {
f, ok := pp.fileMap[relFile]
if !ok {
continue
}
result = append(result, e.extractFile(fset, f, relFile, pkgDir, modulePath, fieldTypes, pkgNames, grpcStubs, pkgVarClients)...)
result = append(result, e.extractFile(fset, f, relFile, pkgDir, modulePath, fieldTypes, pkgNames, grpcStubs, pkgVarClients, baseURLLits)...)
}

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

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 {
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 {
var result []facts.Fact

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

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

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