项目文件夹

文件
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

901 行
29 KiB
Go

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
package contracts
import (
"bytes"
"fmt"
"regexp"
"sort"
"strings"
"github.com/zzet/gortex/internal/graph"
)
// GRPCExtractor detects gRPC service definitions (providers) and client usage (consumers).
type GRPCExtractor struct{}
var (
// Proto service definitions: service Foo { rpc Bar(...) returns (...) }
protoServiceRe = regexp.MustCompile(`(?m)service\s+(\w+)\s*\{`)
protoRPCRe = regexp.MustCompile(`(?m)rpc\s+(\w+)\s*\(`)
// Proto package declaration: `package billing.v1;` — the namespace
// half of the canonical gRPC method name
// `<package>.<Service>/<Method>`.
protoPackageRe = regexp.MustCompile(`(?m)^\s*package\s+([\w.]+)\s*;`)
// Richer RPC pattern that captures the request / response message
// types along with optional `stream` modifiers on either side:
// rpc Foo(FooReq) returns (FooResp);
// rpc Stream(stream Req) returns (stream Resp);
// rpc X(pkg.Foo) returns (pkg.Bar);
//
// Groups:
// 1 = method name
// 2 = "stream" on request (or "")
// 3 = request type (may contain dots: pkg.Foo)
// 4 = "stream" on response (or "")
// 5 = response type
protoRPCShapeRe = regexp.MustCompile(`(?m)rpc\s+(\w+)\s*\(\s*(stream\s+)?([\w.]+)\s*\)\s*returns\s*\(\s*(stream\s+)?([\w.]+)\s*\)`)
// Go consumers
// Pass 1: var := pb.NewUsersClient(conn) — captures (varName, service).
// Accepts any package selector or unqualified, not just "pb.".
goGRPCNewClientAssignRe = regexp.MustCompile(`(?m)(\w+)\s*(?::=|=)\s*(?:[\w.]+\.)?New(\w+)Client\s*\(`)
// Pass 2: varName.MethodName(...) — cross-reference against the map.
goGRPCCallRe = regexp.MustCompile(`(\w+)\s*\.\s*(\w+)\s*\(`)
// Legacy Go "pb.NewServiceClient" pattern — kept so a client
// construction with no assignment (e.g. used inline) still records
// the service as a consumer contract with SymbolID on the
// enclosing function, even when we can't resolve method calls.
goGRPCNewClientRe = regexp.MustCompile(`(?:[\w.]+\.)?New(\w+)Client\s*\(`)
// Go server registration: pb.RegisterUserServiceServer(s, impl).
// The code-side provider anchor — a service-level provider
// contract joins the IDL definition to the implementing repo even
// when per-method handler binding can't resolve.
goGRPCRegisterServerRe = regexp.MustCompile(`(?:[\w.]+\.)?Register(\w+)Server\s*\(`)
// Inline chained call: pb.NewServiceClient(conn).Method(...). The
// constructor's argument list is balance-scanned at match time, so
// the regex only needs to anchor the `New<Service>Client(` head;
// the trailing `.Method(` is matched separately after the scan.
goGRPCMethodHeadRe = regexp.MustCompile(`\s*\.\s*(\w+)\s*\(`)
// TypeScript consumers
tsGRPCNewClientRe = regexp.MustCompile(`new\s+(\w+)Client\(`)
tsGRPCNewClientAssignRe = regexp.MustCompile(`(?m)(?:const|let|var)\s+(\w+)\s*(?::\s*\w+\s*)?=\s*new\s+(\w+)Client\(`)
// stub.methodName( — camelCase per the TS proto generator.
tsGRPCCallRe = regexp.MustCompile(`(\w+)\s*\.\s*(\w+)\s*\(`)
// Python consumers
pyGRPCStubRe = regexp.MustCompile(`(\w+)Stub\(channel`)
pyGRPCStubAssignRe = regexp.MustCompile(`(\w+)\s*=\s*\w*\.(\w+)Stub\(`)
pyGRPCCallRe = regexp.MustCompile(`(\w+)\s*\.\s*(\w+)\s*\(`)
)
func (e *GRPCExtractor) SupportedLanguages() []string {
// "protobuf" matches the parser registry's Language() for .proto
// files. "proto" is retained as a historical alias. Without the
// protobuf entry the dispatch map in Indexer.buildPerFileContract
// Extractors skips proto files entirely and the provider side of
// the gRPC contract model goes missing.
return []string{"protobuf", "proto", "go", "typescript", "python"}
}
// Cheap substring markers that act as a pre-filter before the regex
// scans. Every gRPC consumer pattern in extractConsumers hinges on a
// `Client(` construction (Go, TS) or `Stub(` (Python), and every Go
// server registration on `Server(` — so if the file has none, none of
// the regexes can match. bytes.Contains is ~100× cheaper than a regex
// walk and short-circuits 99% of files in gRPC-free repositories.
var (
grpcClientMarker = []byte("Client(")
grpcStubMarker = []byte("Stub(")
grpcRegisterMarker = []byte("Register")
grpcServerMarker = []byte("Server(")
)
func (e *GRPCExtractor) Extract(filePath string, src []byte, nodes []*graph.Node, edges []*graph.Edge) []Contract {
var contracts []Contract
if strings.HasSuffix(filePath, ".proto") {
contracts = append(contracts, e.extractProtoProviders(filePath, src)...)
return contracts
}
hasClient := bytes.Contains(src, grpcClientMarker) || bytes.Contains(src, grpcStubMarker)
hasRegistration := strings.HasSuffix(filePath, ".go") &&
bytes.Contains(src, grpcRegisterMarker) && bytes.Contains(src, grpcServerMarker)
if !hasClient && !hasRegistration {
return nil
}
fileNodes := filterFileNodes(filePath, nodes)
sort.Slice(fileNodes, func(i, j int) bool {
return fileNodes[i].StartLine < fileNodes[j].StartLine
})
if hasClient {
contracts = append(contracts, e.extractConsumers(filePath, src, fileNodes)...)
}
if hasRegistration {
contracts = append(contracts, e.extractServerRegistrations(filePath, src, fileNodes)...)
}
return contracts
}
// extractServerRegistrations detects Go gRPC server registration
// sites — `pb.RegisterUserServiceServer(s, impl)` — and emits one
// service-level provider contract per registered service. Generated
// stubs name the registration after the service, so the contract ID
// `grpc::<Service>` anchors the implementing repo to the IDL
// definition: the matcher's canonical-name join pairs it with
// method-level consumers/providers of the same service.
//
// The `Register<X>Server(` shape also matches plain function
// definitions (`func RegisterHTTPServer(mux *http.ServeMux)`) and
// helpers with no gRPC involvement. Those are not registration call
// sites, so we reject any match that is a `func` declaration head and
// require real gRPC evidence in the file before treating a match as a
// provider — without the gate, latent `New<X>Client` consumer
// detections gain a spurious exact-ID partner and a false bridge forms.
func (e *GRPCExtractor) extractServerRegistrations(filePath string, src []byte, fileNodes []*graph.Node) []Contract {
text := string(src)
if !fileHasGRPCEvidence(text) {
return nil
}
var out []Contract
lines := strings.Split(text, "\n")
seen := make(map[string]struct{})
for _, m := range goGRPCRegisterServerRe.FindAllStringSubmatchIndex(text, -1) {
svc := text[m[2]:m[3]]
if svc == "" {
continue
}
// Skip function definitions — `func Register<X>Server(...)` is a
// declaration, not a registration call against a *grpc.Server.
if precededByFuncKeyword(text, m[0]) {
continue
}
if _, dup := seen[svc]; dup {
continue
}
// A bare `Register<X>Server(arg)` with no package selector is
// almost always a local helper call or an unqualified definition
// reference, not a generated-stub registration. Generated gRPC
// registration funcs live in the protobuf package and are always
// invoked through it (`pb.RegisterUsersServer(...)`). Accept the
// unqualified form only when the file independently shows gRPC
// involvement (a grpc import), so a same-package registration
// still records while a plain `RegisterHTTPServer(mux)` helper
// call in a grpc-free file does not.
if !matchHasPackageSelector(text, m[0], m[1]) && !fileImportsGRPC(text) {
continue
}
seen[svc] = struct{}{}
ln := lineNumber(lines, m[0])
out = append(out, Contract{
ID: fmt.Sprintf("grpc::%s", svc),
Type: ContractGRPC,
Role: RoleProvider,
SymbolID: findEnclosingSymbol(fileNodes, ln),
FilePath: filePath,
Line: ln,
Meta: map[string]any{
"service": svc,
"lang": "go",
"registration": true,
},
Confidence: 0.85,
})
}
return out
}
// fileHasGRPCEvidence reports whether a Go source file shows any sign of
// being a gRPC server-registration site rather than coincidentally
// containing a `Register<X>Server(` token. Evidence is either a grpc
// import or a package-qualified registration call (`pb.Register…`).
// Files with neither cannot host a real registration, so the registration
// scan is skipped wholesale — preventing a latent `New<X>Client` consumer
// from gaining a spurious exact-ID provider partner.
func fileHasGRPCEvidence(text string) bool {
if fileImportsGRPC(text) {
return true
}
for _, m := range goGRPCRegisterServerRe.FindAllStringSubmatchIndex(text, -1) {
if precededByFuncKeyword(text, m[0]) {
continue
}
if matchHasPackageSelector(text, m[0], m[1]) {
return true
}
}
return false
}
// fileImportsGRPC reports whether the source imports a gRPC package.
// The substring is specific enough that a false hit is implausible.
func fileImportsGRPC(text string) bool {
return strings.Contains(text, "google.golang.org/grpc")
}
// precededByFuncKeyword reports whether the token starting at off is a
// function declaration head — i.e. immediately preceded by the `func `
// keyword (allowing for whitespace). `func RegisterHTTPServer(...)` is a
// definition, not a registration call.
func precededByFuncKeyword(text string, off int) bool {
i := off
// Skip whitespace immediately before the identifier.
for i > 0 && (text[i-1] == ' ' || text[i-1] == '\t') {
i--
}
const kw = "func"
if i < len(kw) {
return false
}
if text[i-len(kw):i] != kw {
return false
}
// Ensure "func" is a standalone keyword, not the tail of an
// identifier like "myfunc".
if i-len(kw) > 0 {
prev := text[i-len(kw)-1]
if prev == '_' || isAlphaNum(prev) {
return false
}
}
return true
}
// matchHasPackageSelector reports whether the registration token whose
// full match spans [start,end) is reached through a package/receiver
// selector (`pb.RegisterUsersServer(`) rather than bare
// (`RegisterHTTPServer(`). The regex's optional `[\w.]+\.` selector
// prefix is part of the match, so a selector is present exactly when the
// matched text holds a `.` ahead of `Register`.
func matchHasPackageSelector(text string, start, end int) bool {
if start < 0 || end > len(text) || start >= end {
return false
}
span := text[start:end]
dot := strings.IndexByte(span, '.')
reg := strings.Index(span, "Register")
return dot >= 0 && reg >= 0 && dot < reg
}
// isAlphaNum reports whether b is an ASCII letter or digit.
func isAlphaNum(b byte) bool {
return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9')
}
func (e *GRPCExtractor) extractProtoProviders(filePath string, src []byte) []Contract {
var contracts []Contract
text := string(src)
lines := strings.Split(text, "\n")
// The proto package declaration provides the namespace half of the
// canonical gRPC method name `<package>.<Service>/<Method>` — the
// identity a client uses on the wire. Contract IDs stay
// package-free (`grpc::<Service>::<Method>`) so cross-repo exact-ID
// pairing keeps working against generated-stub consumers that only
// know the bare service name; the canonical name rides in Meta.
protoPackage := ""
if m := protoPackageRe.FindStringSubmatch(text); m != nil {
protoPackage = m[1]
}
// Build a fast lookup from (methodName → shape) so we can attach
// request/response types to each RPC contract below. We run the
// shape regex across the whole file once; services don't overlap
// and method names are unique within a service, so this is safe.
type shape struct {
requestType string
responseType string
requestStream bool
responseStream bool
}
shapes := make(map[string]shape)
for _, m := range protoRPCShapeRe.FindAllStringSubmatch(text, -1) {
shapes[m[1]] = shape{
requestType: m[3],
responseType: m[5],
requestStream: strings.TrimSpace(m[2]) == "stream",
responseStream: strings.TrimSpace(m[4]) == "stream",
}
}
// Find service blocks and their RPC methods. Each block is
// brace-bounded so a file declaring multiple services doesn't
// attribute a later service's RPCs to an earlier one (the open-ended
// "scan to EOF" form double-counted every method after the first
// service header).
serviceMatches := protoServiceRe.FindAllStringSubmatchIndex(text, -1)
for _, sMatch := range serviceMatches {
serviceName := text[sMatch[2]:sMatch[3]]
serviceStart := sMatch[0]
// sMatch[1] points just past the `{`; balance-scan to the
// closing brace so the RPC scan stays inside this service.
blockEnd := matchCloseBrace(text, sMatch[1])
if blockEnd < 0 {
blockEnd = len(text)
}
block := text[serviceStart:blockEnd]
rpcMatches := protoRPCRe.FindAllStringSubmatch(block, -1)
rpcLocs := protoRPCRe.FindAllStringIndex(block, -1)
qualService := serviceName
if protoPackage != "" {
qualService = protoPackage + "." + serviceName
}
for i, rpc := range rpcMatches {
methodName := rpc[1]
absOffset := serviceStart + rpcLocs[i][0]
line := lineNumber(lines, absOffset)
meta := map[string]any{
"service": serviceName,
"method": methodName,
"canonical": qualService + "/" + methodName,
"schema_source": "none",
}
if protoPackage != "" {
meta["package"] = protoPackage
}
if s, ok := shapes[methodName]; ok {
meta["request_type"] = s.requestType
meta["response_type"] = s.responseType
if s.requestStream {
meta["request_stream"] = true
}
if s.responseStream {
meta["response_stream"] = true
}
meta["schema_source"] = "extracted"
}
contracts = append(contracts, Contract{
ID: fmt.Sprintf("grpc::%s::%s", serviceName, methodName),
Type: ContractGRPC,
Role: RoleProvider,
FilePath: filePath,
Line: line,
Meta: meta,
Confidence: 0.95,
})
}
}
return contracts
}
// matchCloseBrace returns the byte offset of the `}` that closes the
// `{` whose position is just before openEnd (i.e. openEnd points one
// past the opening brace). Returns -1 when the braces are unbalanced.
func matchCloseBrace(text string, openEnd int) int {
if openEnd <= 0 || openEnd > len(text) {
return -1
}
depth := 1
for i := openEnd; i < len(text); i++ {
switch text[i] {
case '{':
depth++
case '}':
depth--
if depth == 0 {
return i
}
}
}
return -1
}
// extractConsumers detects gRPC client usage in a non-proto source file.
// For Go it uses a two-pass scan so per-method RPC calls can emit
// specific "grpc::Service::Method" contracts that match the provider ID
// format produced by extractProtoProviders. Without this, consumer IDs
// were "grpc::Service" while providers were "grpc::Service::Method" —
// so the matcher never paired any gRPC contract and no EdgeMatches
// bridge formed for gRPC. TS/Python stay at the service-level for now
// (client construction only); v1 scope.
func (e *GRPCExtractor) extractConsumers(filePath string, src []byte, fileNodes []*graph.Node) []Contract {
var contracts []Contract
text := string(src)
lines := strings.Split(text, "\n")
// ---- Go pass 1: client construction → varName → serviceName map.
varToService := make(map[string]string)
for _, m := range goGRPCNewClientAssignRe.FindAllStringSubmatchIndex(text, -1) {
varName := text[m[2]:m[3]]
svc := text[m[4]:m[5]]
varToService[varName] = svc
}
// ---- Go pass 2: varName.Method( calls → grpc::Service::Method.
// Track which lines we've already emitted for to avoid duplicates
// when a single file has multiple consumer call sites on one line.
seen := make(map[string]struct{})
for _, m := range goGRPCCallRe.FindAllStringSubmatchIndex(text, -1) {
recv := text[m[2]:m[3]]
method := text[m[4]:m[5]]
svc, ok := varToService[recv]
if !ok {
continue
}
ln := lineNumber(lines, m[0])
key := fmt.Sprintf("%s::%s::%d", svc, method, ln)
if _, dup := seen[key]; dup {
continue
}
seen[key] = struct{}{}
meta := map[string]any{"service": svc, "method": method, "lang": "go"}
// The request message is the second positional argument
// after the context: `client.Method(ctx, &pb.ReqType{...})`
// or `client.Method(ctx, req)`. Capture either the inline
// literal's type or the argument variable's declared type
// from the surrounding window.
if reqType := detectGoGRPCRequestType(text, m[1], fileNodes, ln); reqType != "" {
meta["request_type"] = reqType
meta["schema_source"] = "extracted"
} else {
meta["schema_source"] = "partial"
}
contracts = append(contracts, Contract{
ID: fmt.Sprintf("grpc::%s::%s", svc, method),
Type: ContractGRPC,
Role: RoleConsumer,
SymbolID: findEnclosingSymbol(fileNodes, ln),
FilePath: filePath,
Line: ln,
Meta: meta,
Confidence: 0.9,
})
}
// ---- Go pass 3: inline chained calls.
// pb.NewServiceClient(conn).Method(...) — the stub is constructed
// and the RPC invoked in one expression, with no intermediate
// variable for pass 2 to cross-reference. Balance-scan the
// constructor's argument list, then match the trailing `.Method(`.
for _, m := range goGRPCNewClientRe.FindAllStringSubmatchIndex(text, -1) {
svc := text[m[2]:m[3]]
// m[1] is the offset just past the constructor's opening paren.
argsEnd := matchCloseParen(text, m[1])
if argsEnd < 0 {
continue
}
head := goGRPCMethodHeadRe.FindStringSubmatchIndex(text[argsEnd+1:])
if head == nil || head[0] != 0 {
// Not an immediate `.Method(` chain off the constructor.
continue
}
method := text[argsEnd+1+head[2] : argsEnd+1+head[3]]
ln := lineNumber(lines, m[0])
key := fmt.Sprintf("%s::%s::%d", svc, method, ln)
if _, dup := seen[key]; dup {
continue
}
seen[key] = struct{}{}
meta := map[string]any{"service": svc, "method": method, "lang": "go"}
// The method's argument list starts just past its opening paren.
methodCallEnd := argsEnd + 1 + head[1]
if reqType := detectGoGRPCRequestType(text, methodCallEnd, fileNodes, ln); reqType != "" {
meta["request_type"] = reqType
meta["schema_source"] = "extracted"
} else {
meta["schema_source"] = "partial"
}
contracts = append(contracts, Contract{
ID: fmt.Sprintf("grpc::%s::%s", svc, method),
Type: ContractGRPC,
Role: RoleConsumer,
SymbolID: findEnclosingSymbol(fileNodes, ln),
FilePath: filePath,
Line: ln,
Meta: meta,
Confidence: 0.9,
})
}
// Fallback Go client-construction only (no method-level calls
// resolvable, or the variable was used inline and never assigned).
// Still records that the service is consumed somewhere in this
// file, anchored on the enclosing function of the construction
// site. Skipped when we already emitted a method-level contract
// for this service (the method-level form is strictly more
// informative).
emittedServices := make(map[string]struct{})
for _, c := range contracts {
if svc, _ := c.Meta["service"].(string); svc != "" {
emittedServices[svc] = struct{}{}
}
}
for _, m := range goGRPCNewClientRe.FindAllStringSubmatchIndex(text, -1) {
svc := text[m[2]:m[3]]
if _, already := emittedServices[svc]; already {
continue
}
ln := lineNumber(lines, m[0])
contracts = append(contracts, Contract{
ID: fmt.Sprintf("grpc::%s", svc),
Type: ContractGRPC,
Role: RoleConsumer,
SymbolID: findEnclosingSymbol(fileNodes, ln),
FilePath: filePath,
Line: ln,
Meta: map[string]any{"service": svc, "lang": "go"},
Confidence: 0.7,
})
}
// TS: new ServiceNameClient() — service-level only in v1.
// Also walk the rest of the file for per-method calls and emit
// method-level contracts when we find them, carrying the
// request type from the inline message literal.
tsVarToService := make(map[string]string)
for _, m := range tsGRPCNewClientAssignRe.FindAllStringSubmatch(text, -1) {
tsVarToService[m[1]] = m[2]
}
tsSeen := make(map[string]struct{})
for _, m := range tsGRPCCallRe.FindAllStringSubmatchIndex(text, -1) {
recv := text[m[2]:m[3]]
method := text[m[4]:m[5]]
svc, ok := tsVarToService[recv]
if !ok {
continue
}
ln := lineNumber(lines, m[0])
key := fmt.Sprintf("%s::%s::%d", svc, method, ln)
if _, dup := tsSeen[key]; dup {
continue
}
tsSeen[key] = struct{}{}
meta := map[string]any{"service": svc, "method": method, "lang": "typescript"}
if rt := detectTSGRPCRequestType(text, m[1]); rt != "" {
meta["request_type"] = rt
meta["schema_source"] = "extracted"
} else {
meta["schema_source"] = "partial"
}
contracts = append(contracts, Contract{
ID: fmt.Sprintf("grpc::%s::%s", svc, method),
Type: ContractGRPC,
Role: RoleConsumer,
SymbolID: findEnclosingSymbol(fileNodes, ln),
FilePath: filePath,
Line: ln,
Meta: meta,
Confidence: 0.9,
})
}
// Fallback service-level contracts for TS clients that don't
// have resolvable method calls.
tsEmitted := make(map[string]struct{})
for _, c := range contracts {
if s, _ := c.Meta["service"].(string); s != "" && c.Meta["lang"] == "typescript" {
tsEmitted[s] = struct{}{}
}
}
for _, m := range tsGRPCNewClientRe.FindAllStringSubmatchIndex(text, -1) {
svc := text[m[2]:m[3]]
if _, already := tsEmitted[svc]; already {
continue
}
ln := lineNumber(lines, m[0])
contracts = append(contracts, Contract{
ID: fmt.Sprintf("grpc::%s", svc),
Type: ContractGRPC,
Role: RoleConsumer,
SymbolID: findEnclosingSymbol(fileNodes, ln),
FilePath: filePath,
Line: ln,
Meta: map[string]any{"service": svc, "lang": "typescript"},
Confidence: 0.85,
})
}
// Python: ServiceNameStub(channel) — service-level + method-level
// when stub.GetUser(request_pb2.GetUserRequest(...)) shows up.
pyVarToService := make(map[string]string)
for _, m := range pyGRPCStubAssignRe.FindAllStringSubmatch(text, -1) {
pyVarToService[m[1]] = m[2]
}
pySeen := make(map[string]struct{})
for _, m := range pyGRPCCallRe.FindAllStringSubmatchIndex(text, -1) {
recv := text[m[2]:m[3]]
method := text[m[4]:m[5]]
svc, ok := pyVarToService[recv]
if !ok {
continue
}
ln := lineNumber(lines, m[0])
key := fmt.Sprintf("%s::%s::%d", svc, method, ln)
if _, dup := pySeen[key]; dup {
continue
}
pySeen[key] = struct{}{}
meta := map[string]any{"service": svc, "method": method, "lang": "python"}
if rt := detectPyGRPCRequestType(text, m[1]); rt != "" {
meta["request_type"] = rt
meta["schema_source"] = "extracted"
} else {
meta["schema_source"] = "partial"
}
contracts = append(contracts, Contract{
ID: fmt.Sprintf("grpc::%s::%s", svc, method),
Type: ContractGRPC,
Role: RoleConsumer,
SymbolID: findEnclosingSymbol(fileNodes, ln),
FilePath: filePath,
Line: ln,
Meta: meta,
Confidence: 0.9,
})
}
pyEmitted := make(map[string]struct{})
for _, c := range contracts {
if s, _ := c.Meta["service"].(string); s != "" && c.Meta["lang"] == "python" {
pyEmitted[s] = struct{}{}
}
}
for _, m := range pyGRPCStubRe.FindAllStringSubmatchIndex(text, -1) {
svc := text[m[2]:m[3]]
if _, already := pyEmitted[svc]; already {
continue
}
ln := lineNumber(lines, m[0])
contracts = append(contracts, Contract{
ID: fmt.Sprintf("grpc::%s", svc),
Type: ContractGRPC,
Role: RoleConsumer,
SymbolID: findEnclosingSymbol(fileNodes, ln),
FilePath: filePath,
Line: ln,
Meta: map[string]any{"service": svc, "lang": "python"},
Confidence: 0.85,
})
}
return contracts
}
// detectTSGRPCRequestType picks out the request message type from
// `stub.getUser(new GetUserRequest({...}))` or `stub.getUser(req)`
// where `req: GetUserRequest` is declared nearby.
func detectTSGRPCRequestType(text string, callEnd int) string {
slice := grpcCallArgSlice(text, callEnd)
if slice == "" {
return ""
}
args := splitTopLevelArgs(slice)
if len(args) == 0 {
return ""
}
first := strings.TrimSpace(args[0])
// `new TypeName(...)` or `new pkg.TypeName(...)`.
if strings.HasPrefix(first, "new ") {
rest := strings.TrimSpace(strings.TrimPrefix(first, "new"))
if i := strings.IndexAny(rest, "("); i > 0 {
return strings.TrimSpace(rest[:i])
}
}
return ""
}
// detectPyGRPCRequestType picks out the request type from
// `stub.GetUser(users_pb2.GetUserRequest(id="x"))`.
func detectPyGRPCRequestType(text string, callEnd int) string {
slice := grpcCallArgSlice(text, callEnd)
if slice == "" {
return ""
}
args := splitTopLevelArgs(slice)
if len(args) == 0 {
return ""
}
first := strings.TrimSpace(args[0])
// `mod.TypeName(...)` — Python convention.
if i := strings.Index(first, "("); i > 0 {
// Strip package prefix so the bare name remains.
head := strings.TrimSpace(first[:i])
if dot := strings.LastIndex(head, "."); dot >= 0 {
head = head[dot+1:]
}
return head
}
return ""
}
// matchCloseParen returns the byte offset of the `)` that closes the
// `(` whose closing position is openEnd (i.e. openEnd points just past
// the opening paren). Returns -1 when the parens are unbalanced.
func matchCloseParen(text string, openEnd int) int {
if openEnd <= 0 || openEnd > len(text) {
return -1
}
depth := 1
for i := openEnd; i < len(text); i++ {
switch text[i] {
case '(':
depth++
case ')':
depth--
if depth == 0 {
return i
}
}
}
return -1
}
// grpcCallArgSlice returns the text between the `(` at callEnd-1 and
// its matching `)`, without the outer parens.
func grpcCallArgSlice(text string, callEnd int) string {
if callEnd <= 0 || callEnd > len(text) {
return ""
}
depth := 1
for i := callEnd; i < len(text); i++ {
switch text[i] {
case '(':
depth++
case ')':
depth--
if depth == 0 {
return text[callEnd:i]
}
}
}
return ""
}
// detectGoGRPCRequestType picks the request message type out of a Go
// gRPC client call. It walks the source from `callEnd` (the byte
// offset of the `(` after the method name) to the matching `)`,
// skipping the first arg (context) and inspecting the second. Two
// shapes handled:
//
// client.Method(ctx, &pb.GetUserRequest{...}) → "pb.GetUserRequest"
// client.Method(ctx, req) → look up `req`'s
// declared type in
// a short backward
// window
//
// Returns a bare type name ("pb.GetUserRequest" or "GetUserRequest").
// The module-wide upgrade pass turns it into a full symbol ID.
func detectGoGRPCRequestType(text string, callEnd int, fileNodes []*graph.Node, line int) string {
if callEnd >= len(text) {
return ""
}
// Find the matching `)` for the method call.
depth := 1
end := -1
for i := callEnd; i < len(text); i++ {
switch text[i] {
case '(':
depth++
case ')':
depth--
if depth == 0 {
end = i
}
}
if end >= 0 {
break
}
}
if end < 0 {
return ""
}
// Split args at top-level commas.
args := splitTopLevelArgs(text[callEnd:end])
if len(args) < 2 {
return ""
}
second := strings.TrimSpace(args[1])
// `&pkg.Type{...}` or `pkg.Type{...}` → extract the type prefix.
second = strings.TrimPrefix(second, "&")
if braceIdx := strings.Index(second, "{"); braceIdx >= 0 {
typ := strings.TrimSpace(second[:braceIdx])
if typ != "" {
return typ
}
}
// Bare identifier — look up its declaration.
if isGoIdent(second) {
// Small backward scan within the enclosing function body.
lines := strings.Split(text, "\n")
if line <= 0 || line > len(lines) {
return ""
}
for i := line - 1; i >= 0 && i >= line-30; i-- {
if typ := findGoVarTypeInLine(lines[i], second); typ != "" {
return typ
}
}
}
// File-scoped node lookup as last resort: same-file variable or
// field declaration.
_ = fileNodes
return ""
}
// splitTopLevelArgs breaks a parenthesised argument list at commas
// that aren't nested inside other parens / braces / brackets.
func splitTopLevelArgs(s string) []string {
var out []string
depth := 0
start := 0
for i := 0; i < len(s); i++ {
switch s[i] {
case '(', '[', '{':
depth++
case ')', ']', '}':
depth--
case ',':
if depth == 0 {
out = append(out, s[start:i])
start = i + 1
}
}
}
if last := strings.TrimSpace(s[start:]); last != "" {
out = append(out, last)
}
return out
}
func isGoIdent(s string) bool {
s = strings.TrimSpace(s)
if s == "" {
return false
}
for i, r := range s {
if r == '_' || (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') {
continue
}
if i > 0 && r >= '0' && r <= '9' {
continue
}
return false
}
return true
}
// findGoVarTypeInLine looks for `name := &Type{...}`, `name := Type{...}`,
// `var name Type`, or `var name *Type` on a single source line and
// returns the type if found.
func findGoVarTypeInLine(line, name string) string {
ln := strings.TrimSpace(line)
prefixes := []string{name + " :=", name + ":="}
for _, pfx := range prefixes {
if strings.HasPrefix(ln, pfx) {
rest := strings.TrimSpace(strings.TrimPrefix(ln, pfx))
rest = strings.TrimPrefix(rest, "&")
if idx := strings.Index(rest, "{"); idx >= 0 {
return strings.TrimSpace(rest[:idx])
}
return ""
}
}
if strings.HasPrefix(ln, "var "+name+" ") {
rest := strings.TrimSpace(strings.TrimPrefix(ln, "var "+name+" "))
rest = strings.TrimPrefix(rest, "*")
// Cut at `=` or end.
if idx := strings.Index(rest, "="); idx >= 0 {
rest = rest[:idx]
}
return strings.TrimSpace(rest)
}
return ""
}
// lineNumber returns the 1-based line number for the given byte offset.
func lineNumber(lines []string, offset int) int {
pos := 0
for i, l := range lines {
end := pos + len(l) + 1 // +1 for newline
if offset < end {
return i + 1
}
pos = end
}
return len(lines)
}