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
355 lines
13 KiB
Go
355 lines
13 KiB
Go
package contracts
|
|
|
|
import (
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/zzet/gortex/internal/graph"
|
|
)
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Go — net/http (stdlib) provider
|
|
// -----------------------------------------------------------------------------
|
|
|
|
// Matches:
|
|
//
|
|
// json.NewDecoder(r.Body).Decode(&req)
|
|
// jsoniter.NewDecoder(r.Body).Decode(&req)
|
|
// decoder.Decode(&req)
|
|
//
|
|
// The capture is the target variable; the detector then looks up its
|
|
// type inside the handler body or the file-scoped node list.
|
|
var goStdlibDecodeRe = regexp.MustCompile(`(?:json|jsoniter|decoder)\.?(?:NewDecoder)?\([^)]*\.Body\)?\.Decode\(\s*&?(\w+)\s*\)`)
|
|
|
|
// Matches:
|
|
//
|
|
// json.Unmarshal(body, &req) | json.Unmarshal(data, req)
|
|
var goUnmarshalRe = regexp.MustCompile(`json\.Unmarshal\([^,]+,\s*&?(\w+)\s*\)`)
|
|
|
|
// Matches provider-side response encoders:
|
|
//
|
|
// json.NewEncoder(w).Encode(resp)
|
|
// WriteJSON(w, status, resp)
|
|
var goStdlibEncodeRe = regexp.MustCompile(`json\.NewEncoder\([^)]+\)\.Encode\(\s*&?(\w+)\s*\)`)
|
|
|
|
// JSON response helpers. Custom wrappers are the norm in handwritten
|
|
// Go servers — `respondJSON`, `writeJSON`, `sendJSON`, `renderJSON`,
|
|
// `h.json`, `render.JSON` all converge on the same (w, code, value)
|
|
// shape. Matching any of these gets us the status code and response
|
|
// value expression in one pass. The name capture is case-insensitive
|
|
// only for the leading letter so `WriteJSON` still matches.
|
|
var goWriteJSONRe = regexp.MustCompile(`\b(?:[A-Za-z_]\w*\.)?(?:[Rr]espond|[Ww]rite|[Ss]end|[Rr]ender)(?:JSON|Json)\(\s*\w+\s*,\s*([^,]+?)\s*,\s*([^)]+?)\s*\)`)
|
|
|
|
// (Envelope splitting now uses splitMapLiteralBody — a brace/bracket-
|
|
// balanced byte walker — rather than a regex. The regex form
|
|
// truncated nested literals like `[]any{evt1, evt2}` at the first
|
|
// `}` and produced unusable expr values like `"[]any{"`.)
|
|
|
|
// r.URL.Query().Get("x"), r.FormValue("x"), r.PostFormValue("x").
|
|
var goQueryParamRe = regexp.MustCompile(`\b(?:URL\.Query\(\)\.Get|FormValue|PostFormValue)\(\s*["` + "`" + `]([^"` + "`" + `]+)["` + "`" + `]\s*\)`)
|
|
|
|
// w.WriteHeader(<expr>) — literal int or http.StatusX.
|
|
var goWriteHeaderRe = regexp.MustCompile(`\bWriteHeader\(\s*([^)]+?)\s*\)`)
|
|
|
|
// Return bare status literal: "return http.StatusBadRequest" in helpers.
|
|
var goStatusConstRe = regexp.MustCompile(`\bhttp\.(Status[A-Z]\w+)\b`)
|
|
|
|
func init() {
|
|
// Provider-side detectors. Each one's regex is narrow enough that
|
|
// running all of them on every Go provider handler doesn't cause
|
|
// cross-framework false positives.
|
|
schemaEnrichers = append(schemaEnrichers,
|
|
schemaEnricher{
|
|
name: "go-stdlib-provider",
|
|
languages: []string{"go"},
|
|
roles: []Role{RoleProvider},
|
|
detect: goNetHTTPDetect,
|
|
},
|
|
schemaEnricher{
|
|
name: "go-gin-provider",
|
|
languages: []string{"go"},
|
|
roles: []Role{RoleProvider},
|
|
detect: goGinDetect,
|
|
},
|
|
schemaEnricher{
|
|
name: "go-fiber-provider",
|
|
languages: []string{"go"},
|
|
roles: []Role{RoleProvider},
|
|
detect: goFiberDetect,
|
|
},
|
|
schemaEnricher{
|
|
name: "go-echo-provider",
|
|
languages: []string{"go"},
|
|
roles: []Role{RoleProvider},
|
|
detect: goEchoDetect,
|
|
},
|
|
|
|
// Consumer side — picks up the outgoing payload and the
|
|
// decode target around the call site. Same detector handles
|
|
// all Go HTTP clients (stdlib, resty, etc.) because the
|
|
// surrounding idioms are the same.
|
|
schemaEnricher{
|
|
name: "go-consumer",
|
|
languages: []string{"go"},
|
|
roles: []Role{RoleConsumer},
|
|
detect: goConsumerDetect,
|
|
},
|
|
)
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Go provider detectors
|
|
// -----------------------------------------------------------------------------
|
|
|
|
func goNetHTTPDetect(body string, fileNodes []*graph.Node) schemaHints {
|
|
var h schemaHints
|
|
|
|
if m := goStdlibDecodeRe.FindStringSubmatch(body); len(m) > 1 {
|
|
setRequestType(&h, m[1], body, fileNodes, m[0])
|
|
} else if m := goUnmarshalRe.FindStringSubmatch(body); len(m) > 1 {
|
|
setRequestType(&h, m[1], body, fileNodes, m[0])
|
|
}
|
|
|
|
if m := goStdlibEncodeRe.FindStringSubmatch(body); len(m) > 1 {
|
|
setResponseType(&h, m[1], body, fileNodes, m[0])
|
|
} else if m := goWriteJSONRe.FindStringSubmatch(body); len(m) > 2 {
|
|
if code, ok := parseStatusExpr(m[1]); ok {
|
|
h.StatusCodes = append(h.StatusCodes, code)
|
|
}
|
|
setResponseType(&h, m[2], body, fileNodes, m[0])
|
|
}
|
|
|
|
h.QueryParams = append(h.QueryParams, allSubmatches(body, goQueryParamRe, 1)...)
|
|
for _, m := range goWriteHeaderRe.FindAllStringSubmatch(body, -1) {
|
|
if code, ok := parseStatusExpr(m[1]); ok {
|
|
h.StatusCodes = append(h.StatusCodes, code)
|
|
}
|
|
}
|
|
for _, m := range goStatusConstRe.FindAllStringSubmatch(body, -1) {
|
|
if code, ok := parseStatusExpr("http." + m[1]); ok {
|
|
h.StatusCodes = append(h.StatusCodes, code)
|
|
}
|
|
}
|
|
return h
|
|
}
|
|
|
|
// Gin: c.BindJSON / c.ShouldBindJSON / c.ShouldBind, c.JSON(status, obj),
|
|
// c.Query("x"), c.DefaultQuery("x", ...), c.Param("x").
|
|
var (
|
|
ginBindRe = regexp.MustCompile(`\b(?:ShouldBindJSON|BindJSON|ShouldBind)\(\s*&?(\w+)\s*\)`)
|
|
ginJSONRe = regexp.MustCompile(`\.JSON\(\s*([^,]+?)\s*,\s*([A-Za-z_][\w\.]*(?:\{[^}]*\})?)\s*\)`)
|
|
ginQueryRe = regexp.MustCompile(`\b(?:DefaultQuery|Query)\(\s*["` + "`" + `]([^"` + "`" + `]+)["` + "`" + `]`)
|
|
ginStatusRe = regexp.MustCompile(`\.Status\(\s*([^)]+?)\s*\)`)
|
|
)
|
|
|
|
func goGinDetect(body string, fileNodes []*graph.Node) schemaHints {
|
|
var h schemaHints
|
|
if m := ginBindRe.FindStringSubmatch(body); len(m) > 1 {
|
|
setRequestType(&h, m[1], body, fileNodes, m[0])
|
|
}
|
|
for _, m := range ginJSONRe.FindAllStringSubmatch(body, -1) {
|
|
if code, ok := parseStatusExpr(m[1]); ok {
|
|
h.StatusCodes = append(h.StatusCodes, code)
|
|
}
|
|
setResponseType(&h, m[2], body, fileNodes, m[0])
|
|
}
|
|
h.QueryParams = append(h.QueryParams, allSubmatches(body, ginQueryRe, 1)...)
|
|
for _, m := range ginStatusRe.FindAllStringSubmatch(body, -1) {
|
|
if code, ok := parseStatusExpr(m[1]); ok {
|
|
h.StatusCodes = append(h.StatusCodes, code)
|
|
}
|
|
}
|
|
// Pick up any bare http.StatusX references too.
|
|
for _, m := range goStatusConstRe.FindAllStringSubmatch(body, -1) {
|
|
if code, ok := parseStatusExpr("http." + m[1]); ok {
|
|
h.StatusCodes = append(h.StatusCodes, code)
|
|
}
|
|
}
|
|
return h
|
|
}
|
|
|
|
// Fiber: c.BodyParser(&req), c.JSON(obj), c.Status(200), c.Query("x").
|
|
var (
|
|
fiberBindRe = regexp.MustCompile(`\bBodyParser\(\s*&?(\w+)\s*\)`)
|
|
fiberJSONRe = regexp.MustCompile(`\.JSON\(\s*&?([A-Za-z_][\w\.]*(?:\{[^}]*\})?)\s*\)`)
|
|
fiberQueryRe = regexp.MustCompile(`\.Query\(\s*["` + "`" + `]([^"` + "`" + `]+)["` + "`" + `]`)
|
|
fiberStatusRe = regexp.MustCompile(`\.Status\(\s*([^)]+?)\s*\)`)
|
|
)
|
|
|
|
func goFiberDetect(body string, fileNodes []*graph.Node) schemaHints {
|
|
var h schemaHints
|
|
if m := fiberBindRe.FindStringSubmatch(body); len(m) > 1 {
|
|
setRequestType(&h, m[1], body, fileNodes, m[0])
|
|
}
|
|
if m := fiberJSONRe.FindStringSubmatch(body); len(m) > 1 {
|
|
setResponseType(&h, m[1], body, fileNodes, m[0])
|
|
}
|
|
h.QueryParams = append(h.QueryParams, allSubmatches(body, fiberQueryRe, 1)...)
|
|
for _, m := range fiberStatusRe.FindAllStringSubmatch(body, -1) {
|
|
if code, ok := parseStatusExpr(m[1]); ok {
|
|
h.StatusCodes = append(h.StatusCodes, code)
|
|
}
|
|
}
|
|
for _, m := range goStatusConstRe.FindAllStringSubmatch(body, -1) {
|
|
if code, ok := parseStatusExpr("http." + m[1]); ok {
|
|
h.StatusCodes = append(h.StatusCodes, code)
|
|
}
|
|
}
|
|
return h
|
|
}
|
|
|
|
// Echo: c.Bind(&req), c.JSON(status, obj), c.QueryParam("x"),
|
|
// c.Param("x").
|
|
var (
|
|
echoBindRe = regexp.MustCompile(`\bc\.Bind\(\s*&?(\w+)\s*\)`)
|
|
echoQueryRe = regexp.MustCompile(`\bQueryParam\(\s*["` + "`" + `]([^"` + "`" + `]+)["` + "`" + `]`)
|
|
)
|
|
|
|
func goEchoDetect(body string, fileNodes []*graph.Node) schemaHints {
|
|
var h schemaHints
|
|
if m := echoBindRe.FindStringSubmatch(body); len(m) > 1 {
|
|
setRequestType(&h, m[1], body, fileNodes, m[0])
|
|
}
|
|
// Echo's JSON signature is identical to gin's, so the gin regex
|
|
// also fires here via the shared enricher chain — the driver
|
|
// merges. But we still pull status codes from h.WriteHeader etc.
|
|
for _, m := range ginJSONRe.FindAllStringSubmatch(body, -1) {
|
|
if code, ok := parseStatusExpr(m[1]); ok {
|
|
h.StatusCodes = append(h.StatusCodes, code)
|
|
}
|
|
setResponseType(&h, m[2], body, fileNodes, m[0])
|
|
}
|
|
h.QueryParams = append(h.QueryParams, allSubmatches(body, echoQueryRe, 1)...)
|
|
for _, m := range goStatusConstRe.FindAllStringSubmatch(body, -1) {
|
|
if code, ok := parseStatusExpr("http." + m[1]); ok {
|
|
h.StatusCodes = append(h.StatusCodes, code)
|
|
}
|
|
}
|
|
return h
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Go consumer detector — best-effort extraction of the outgoing payload
|
|
// and any decode target near the call site.
|
|
// -----------------------------------------------------------------------------
|
|
|
|
var (
|
|
// Outbound body: json.Marshal(<expr>) within the call window.
|
|
goMarshalRe = regexp.MustCompile(`json\.Marshal\(\s*&?(\w+)\s*\)`)
|
|
// Decode target: json.NewDecoder(resp.Body).Decode(&result) or
|
|
// json.Unmarshal(body, &result).
|
|
goDecodeRespRe = regexp.MustCompile(`json\.NewDecoder\([^)]+\.Body\)\.Decode\(\s*&?(\w+)\s*\)`)
|
|
)
|
|
|
|
func goConsumerDetect(body string, fileNodes []*graph.Node) schemaHints {
|
|
var h schemaHints
|
|
if m := goMarshalRe.FindStringSubmatch(body); len(m) > 1 {
|
|
setRequestType(&h, m[1], body, fileNodes, m[0])
|
|
}
|
|
if m := goDecodeRespRe.FindStringSubmatch(body); len(m) > 1 {
|
|
setResponseType(&h, m[1], body, fileNodes, m[0])
|
|
} else if m := goUnmarshalRe.FindStringSubmatch(body); len(m) > 1 {
|
|
setResponseType(&h, m[1], body, fileNodes, m[0])
|
|
}
|
|
return h
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Shared helpers for Go detectors
|
|
// -----------------------------------------------------------------------------
|
|
|
|
// setRequestType records a request-type hint when the regex pattern
|
|
// captured an identifier that's directly a type name (e.g.
|
|
// `Decode(&Request{})` → "Request"). Variable-to-type resolution and
|
|
// envelope walking moved to the BodyFacts AST overlay (Phase 1 of
|
|
// spec-contract-extraction.md), which runs after this function and
|
|
// overrides the hint with structurally-correct facts.
|
|
//
|
|
// The body parameter is retained for signature symmetry with the
|
|
// callers (the regex detectors take body as their primary input).
|
|
func setRequestType(h *schemaHints, ident, body string, fileNodes []*graph.Node, matchText string) {
|
|
_ = body
|
|
if looksLikeType(ident) {
|
|
h.RequestType = resolveTypeInFile(ident, fileNodes)
|
|
return
|
|
}
|
|
if h.RequestExpr == "" {
|
|
h.RequestExpr = strings.TrimSpace(matchText)
|
|
}
|
|
}
|
|
|
|
// setResponseType records a response-type hint. Type resolution and
|
|
// envelope walking moved to the BodyFacts AST overlay (Phase 1 of
|
|
// spec-contract-extraction.md). This function only handles the cases
|
|
// the AST overlay can't override safely:
|
|
//
|
|
// 1. ident is itself a type name (e.g. `JSON(http.StatusOK, ErrResp{...})`)
|
|
// 2. ident is a compound literal expression — record as ResponseExpr
|
|
// for the post-pass to chase
|
|
// 3. ident is a bare identifier — record as ResponseExpr; the AST
|
|
// overlay will replace it if it can resolve the var binding
|
|
//
|
|
// The body parameter is retained for signature symmetry; it's no
|
|
// longer consulted (the AST overlay reads from the parse tree).
|
|
func setResponseType(h *schemaHints, ident, body string, fileNodes []*graph.Node, matchText string) {
|
|
_ = body
|
|
trimmed := strings.TrimSpace(ident)
|
|
if looksLikeType(trimmed) {
|
|
h.ResponseType = resolveTypeInFile(trimmed, fileNodes)
|
|
return
|
|
}
|
|
if h.ResponseExpr == "" {
|
|
switch {
|
|
case isCompoundExpr(trimmed):
|
|
h.ResponseExpr = trimmed
|
|
case isPlainIdent(trimmed):
|
|
h.ResponseExpr = trimmed
|
|
default:
|
|
h.ResponseExpr = strings.TrimSpace(matchText)
|
|
}
|
|
}
|
|
}
|
|
|
|
func isCompoundExpr(s string) bool {
|
|
return strings.ContainsAny(s, "{[(")
|
|
}
|
|
|
|
// looksLikeType is a quick heuristic: starts with an uppercase letter,
|
|
// contains only identifier-ish characters. Filters out things like
|
|
// "err" or "nil" while keeping "LoginRequest" and "pkg.User".
|
|
func looksLikeType(s string) bool {
|
|
s = strings.TrimSpace(s)
|
|
s = strings.TrimPrefix(s, "&")
|
|
s = strings.TrimPrefix(s, "*")
|
|
if s == "" {
|
|
return false
|
|
}
|
|
// Drop trailing literal `{}` so `Foo{}` still counts as a type.
|
|
if i := strings.Index(s, "{"); i >= 0 {
|
|
s = s[:i]
|
|
}
|
|
first := rune(s[0])
|
|
if first < 'A' || first > 'Z' {
|
|
// `pkg.User` — check the part after the last dot too.
|
|
if i := strings.LastIndex(s, "."); i >= 0 {
|
|
return looksLikeType(s[i+1:])
|
|
}
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func allSubmatches(body string, re *regexp.Regexp, grp int) []string {
|
|
ms := re.FindAllStringSubmatch(body, -1)
|
|
if len(ms) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]string, 0, len(ms))
|
|
for _, m := range ms {
|
|
if grp < len(m) {
|
|
out = append(out, m[grp])
|
|
}
|
|
}
|
|
return out
|
|
}
|