Files
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

343 lines
11 KiB
Go

package contracts
import (
"strings"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/parser"
sitter "github.com/zzet/gortex/internal/parser/tsitter"
)
// goRouteMatch is the AST equivalent of one `httpPatterns` regex
// match. It carries the same shape the regex pipeline produced so
// HTTPExtractor.extract can stitch contracts the same way regardless
// of which detector found the route.
type goRouteMatch struct {
method string // "GET" / "ANY" / etc.
path string // raw path text (with the verb prefix stripped for Go 1.22+ form)
handlerIdent string // first identifier in the handler-arg position, or ""
handlerTrail string // full source span of the call's arguments, or ""
line int // 1-based line of the call_expression
framework string // "net/http" / "gin/echo/chi" / "fiber"
confidence float64
}
// detectGoRoutesAST walks the parse tree's call_expressions and
// returns every HTTP route registration. Replaces the four Go entries
// in httpPatterns; structurally distinguishes a string literal inside
// `[]byte(".GET(")` (which is *not* a route) from `.GET("/users", h)`
// (which is) — the regex layer couldn't.
func detectGoRoutesAST(root *sitter.Node, src []byte, filePath, repoPrefix string, store EndpointConstStore) []goRouteMatch {
if root == nil {
return nil
}
var out []goRouteMatch
walkGoCallExprs(root, func(call *sitter.Node) {
if m, ok := goRouteFromCall(call, src, filePath, repoPrefix, store); ok {
out = append(out, m)
}
})
return out
}
// walkGoCallExprs descends through every named child of the root,
// invoking fn on each call_expression. We don't run a tree-sitter
// query here — the manual walk is faster (single C-side pass per
// node) and the predicate logic is simple enough.
func walkGoCallExprs(n *sitter.Node, fn func(*sitter.Node)) {
if n == nil {
return
}
if n.Type() == "call_expression" {
fn(n)
}
for i := 0; i < int(n.NamedChildCount()); i++ {
ch := n.NamedChild(i)
if ch == nil {
continue
}
walkGoCallExprs(ch, fn)
}
}
// goRouteFromCall classifies a call_expression as an HTTP route
// registration. Returns (match, true) when it matches one of the
// four supported shapes; (zero, false) otherwise.
func goRouteFromCall(call *sitter.Node, src []byte, filePath, repoPrefix string, store EndpointConstStore) (goRouteMatch, bool) {
fn := call.ChildByFieldName("function")
if fn == nil || fn.Type() != "selector_expression" {
return goRouteMatch{}, false
}
field := fn.ChildByFieldName("field")
if field == nil {
return goRouteMatch{}, false
}
methodTok := field.Content(src)
args := call.ChildByFieldName("arguments")
if args == nil {
return goRouteMatch{}, false
}
argList := namedChildren(args)
if len(argList) == 0 {
return goRouteMatch{}, false
}
// Resolve the path argument graph-wide: a string literal, a const
// identifier (so `const P = "/api/x"; r.GET(P, h)` mints a route — even
// when P is defined in another file of the same repo), or a composite
// literal's endpoint field. forRoute=true vets a const / composite
// dereference through the route guard so a value that is really a
// filesystem path is dropped; a plain literal is returned unchanged.
pathArg := argList[0]
pathText, ok := ResolveEndpointArg(pathArg, src, filePath, repoPrefix, store, true)
if !ok {
return goRouteMatch{}, false
}
// Identify framework + method by the field name and path shape.
switch methodTok {
case "Handle", "HandleFunc":
// net/http: receiver name is anything (mux, router, http) — we
// don't care. Method is embedded in the path for Go 1.22+ or
// "ANY" for legacy. Path must start with `/` (legacy) OR
// "VERB /" (Go 1.22+).
method, path, ok := splitNetHTTPPattern(pathText)
if !ok {
return goRouteMatch{}, false
}
conf := 0.9
if method != "ANY" {
conf = 0.95
}
return finishGoRoute(call, args, argList, src, method, path, "net/http", conf), true
case "Get", "Post", "Put", "Delete", "Patch", "Head", "Options":
// gin/echo/chi: lowercase-method call on a router-shaped
// receiver. The original regex restricted receivers to
// {r, g, e, router, group, api, v1, mux, app}. Tightening
// further is risky — production code uses other names.
// Reject when receiver is `http` (avoids matching
// http.Get(url) consumer patterns).
if isHTTPStdlibConsumer(fn, src) {
return goRouteMatch{}, false
}
// Path must start with `/` to avoid false-matching arbitrary
// `.Get("name")` calls on non-router types.
if !strings.HasPrefix(pathText, "/") {
return goRouteMatch{}, false
}
return finishGoRoute(call, args, argList, src, strings.ToUpper(methodTok), pathText, "gin/echo/chi", 0.9), true
case "GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS":
// fiber: uppercase method call. Path must start with `/`.
if !strings.HasPrefix(pathText, "/") {
return goRouteMatch{}, false
}
return finishGoRoute(call, args, argList, src, methodTok, pathText, "fiber", 0.9), true
}
return goRouteMatch{}, false
}
// finishGoRoute builds the goRouteMatch with handler metadata.
func finishGoRoute(
call *sitter.Node,
args *sitter.Node,
argList []*sitter.Node,
src []byte,
method, path, framework string,
confidence float64,
) goRouteMatch {
m := goRouteMatch{
method: method,
path: path,
framework: framework,
confidence: confidence,
line: int(call.StartPoint().Row) + 1,
}
if len(argList) >= 2 {
// handlerIdent is the FIRST identifier we can pluck from the
// handler-arg position. For wrapped calls (`WithAuth(h, fn)`)
// this is the wrapper, but it's enough for the post-pass to
// resolve via the call-trail.
handlerArg := argList[1]
m.handlerIdent = extractFirstHandlerIdent(handlerArg, src)
// handlerTrail is the full source span of the args list,
// excluding the leading "(" and trailing ")".
argText := args.Content(src)
argText = strings.TrimPrefix(argText, "(")
argText = strings.TrimSuffix(argText, ")")
m.handlerTrail = strings.TrimSpace(argText)
}
return m
}
// extractFirstHandlerIdent returns the first identifier-shaped name
// in the handler-arg expression. For:
//
// listUsers → "listUsers"
// h.CreateUser → "h" (matches the regex output)
// WithAuth(h.CreateUser) → "WithAuth"
//
// The post-pass's resolveProviderHandlers then walks handlerTrail to
// find the innermost-resolvable handler.
func extractFirstHandlerIdent(node *sitter.Node, src []byte) string {
if node == nil {
return ""
}
switch node.Type() {
case "identifier":
return node.Content(src)
case "selector_expression":
// `h.CreateUser` — match regex behaviour: capture "h" only.
op := node.ChildByFieldName("operand")
if op != nil {
return extractFirstHandlerIdent(op, src)
}
case "call_expression":
// Wrapper call: pull the function ident.
fn := node.ChildByFieldName("function")
if fn != nil {
return extractFirstHandlerIdent(fn, src)
}
}
return ""
}
// stringLiteralValue returns the unquoted text of a string literal
// node. Returns ("", false) if the node isn't a string literal.
func stringLiteralValue(n *sitter.Node, src []byte) (string, bool) {
if n == nil {
return "", false
}
switch n.Type() {
case "interpreted_string_literal", "raw_string_literal":
return unquoteStringLiteral(n.Content(src)), true
}
return "", false
}
// splitNetHTTPPattern handles the Go 1.22+ "VERB /path" form vs
// legacy "/path". Returns (method, path, ok).
//
// "GET /users" → ("GET", "/users", true)
// "/users" → ("ANY", "/users", true)
// "users" → ("", "", false) ← rejected: no leading /
func splitNetHTTPPattern(s string) (string, string, bool) {
if strings.HasPrefix(s, "/") {
return "ANY", s, true
}
// "VERB /path" — verb is one of GET/POST/.../OPTIONS, then space,
// then path starting with /.
idx := strings.IndexByte(s, ' ')
if idx <= 0 {
return "", "", false
}
verb := s[:idx]
path := strings.TrimSpace(s[idx+1:])
if !isHTTPVerb(verb) || !strings.HasPrefix(path, "/") {
return "", "", false
}
return verb, path, true
}
func isHTTPVerb(s string) bool {
switch s {
case "GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS", "CONNECT", "TRACE":
return true
}
return false
}
// buildGoRouteContract turns a goRouteMatch into a Contract,
// mirroring the regex pipeline's stitching: subtree handling for
// net/http trailing slashes, NormalizeHTTPPathWithParams for path
// params, handler-ident scratchpad for the resolveProviderHandlers
// post-pass, and an EnrichHTTPContractWithTree call for body facts.
func buildGoRouteContract(
rm goRouteMatch,
filePath string,
fileNodes []*graph.Node,
lines []string,
lang string,
tree *parser.ParseTree,
_ string, // text: kept for signature symmetry with the regex caller
_ []byte, // src
) Contract {
path := rm.path
subtree := false
// Same trailing-slash subtree treatment net/http gets in the regex
// pipeline (http.go ~line 600). gin/echo/chi/fiber treat trailing
// slash as a literal, so we only patch for net/http.
if rm.framework == "net/http" && len(path) > 1 && strings.HasSuffix(path, "/") {
path = strings.TrimRight(path, "/") + "/{rest}"
subtree = true
}
normPath, origNames := NormalizeHTTPPathWithParams(path)
contractID := "http::" + rm.method + "::" + normPath
symbolID := findEnclosingSymbol(fileNodes, rm.line)
// Re-point SymbolID at the actual handler when we can resolve it
// in the same file. Bare handler: r.GET("/x", listUsers) → resolve
// "listUsers". Wrapped: WithAuth(h.Foo) → fall through to
// findInnermostResolvableHandler on the trail.
if rm.handlerIdent != "" {
if hID := resolveHandlerIdent(fileNodes, rm.handlerIdent); hID != "" {
symbolID = hID
} else if hID := findInnermostResolvableHandler(fileNodes, rm.handlerTrail); hID != "" {
symbolID = hID
}
}
meta := map[string]any{
"method": rm.method,
"path": normPath,
"framework": rm.framework,
}
if len(origNames) > 0 {
meta["path_param_names"] = origNames
}
if rm.handlerIdent != "" {
meta["handler_ident"] = rm.handlerIdent
}
if rm.handlerTrail != "" {
meta["handler_trail"] = rm.handlerTrail
}
if subtree {
meta["subtree"] = true
}
c := Contract{
ID: contractID,
Type: ContractHTTP,
Role: RoleProvider,
SymbolID: symbolID,
FilePath: filePath,
Line: rm.line,
Meta: meta,
Confidence: rm.confidence,
}
EnrichHTTPContractWithTree(&c, lines, fileNodes, lang, tree)
return c
}
// isHTTPStdlibConsumer reports whether the selector_expression
// receiver is the `http` package (in which case `http.Get(url, …)`
// is a CONSUMER, not a route registration).
func isHTTPStdlibConsumer(fn *sitter.Node, src []byte) bool {
if fn == nil {
return false
}
op := fn.ChildByFieldName("operand")
if op == nil {
return false
}
if op.Type() != "identifier" {
return false
}
return op.Content(src) == "http"
}