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
343 lines
11 KiB
Go
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"
|
|
}
|