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
901 lines
29 KiB
Go
901 lines
29 KiB
Go
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)
|
||
}
|