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

595 lines
18 KiB
Go

package contracts
import (
"regexp"
"sort"
"strconv"
"strings"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/parser"
)
// schemaHints collects the structured fields a single enricher extracts
// from a handler body. Each field is optional — a detector populates
// only what it can pin down from the text. The driver merges hints
// from every applicable enricher before writing to Contract.Meta.
type schemaHints struct {
// RequestType is the symbol ID or bare type name of the request
// body. A symbol ID is emitted when the type is defined in the
// same file as the handler (cheap to resolve via the file-scoped
// node list); otherwise the bare name is stored so a later
// module-wide pass can upgrade it.
RequestType string
// RequestExpr is the raw source expression used to receive the
// body when no type name could be pulled out (e.g.
// `c.BindJSON(map[string]any{})`). Kept so the UI can at least
// point at the binding call site.
RequestExpr string
ResponseType string
ResponseExpr string
// ResponseRepeated is true when the captured type was a list
// shape (`Foo[]`, `Array<Foo>`, `[]Foo`), so the dashboard can
// render the response as an array of Foo without losing the
// underlying type.
ResponseRepeated bool
// ResponseEnvelope is the structured form of an inline map response
// like `map[string]any{"files": out, "total": count}`. Each field
// records the JSON key, the source expression that fed it, and
// (when resolvable) the inferred type. The dashboard prefers this
// over ResponseExpr when present so the schema view shows a real
// shape instead of the raw response-helper call.
ResponseEnvelope []envelopeField
QueryParams []string
StatusCodes []int
}
// envelopeField is one key in an inline JSON envelope literal. Type
// is best-effort — empty when the value couldn't be traced to a
// concrete declaration; Expr is always the trimmed source expression.
// Repeated is true when the value's declared type was a slice
// (`[]Foo` or `make([]Foo, …)`), so the dashboard can render the
// field as an array.
type envelopeField struct {
Name string
Expr string
Type string
Repeated bool
}
func (h *schemaHints) isEmpty() bool {
return h.RequestType == "" &&
h.RequestExpr == "" &&
h.ResponseType == "" &&
h.ResponseExpr == "" &&
len(h.ResponseEnvelope) == 0 &&
len(h.QueryParams) == 0 &&
len(h.StatusCodes) == 0
}
func (h *schemaHints) merge(o schemaHints) {
if h.RequestType == "" {
h.RequestType = o.RequestType
}
if h.RequestExpr == "" {
h.RequestExpr = o.RequestExpr
}
if h.ResponseType == "" {
h.ResponseType = o.ResponseType
// ResponseRepeated travels with ResponseType: only adopt it
// when we adopt the underlying type, so a later enricher
// can't promote a non-list type to a list one (or vice
// versa) in isolation.
h.ResponseRepeated = o.ResponseRepeated
}
if h.ResponseExpr == "" {
h.ResponseExpr = o.ResponseExpr
}
if len(h.ResponseEnvelope) == 0 {
h.ResponseEnvelope = o.ResponseEnvelope
}
h.QueryParams = append(h.QueryParams, o.QueryParams...)
h.StatusCodes = append(h.StatusCodes, o.StatusCodes...)
}
// schemaEnricher is a per-(language, role) body scanner. Each detector
// looks for binding / encoding / query / status patterns specific to
// its framework and returns the hints it found. Framework gating is
// intentionally absent — regex specificity already keeps a gin
// detector from firing on fiber code, and upstream httpPatterns
// sometimes mis-label the framework tag (e.g. a `r.POST(...)`
// declaration in a gin file gets tagged "fiber" because the uppercase
// pattern is shared). Role gating is explicit: provider-side decoders
// would otherwise match response-body decodes on the consumer side.
type schemaEnricher struct {
name string
languages []string // empty = any
roles []Role // empty = any
detect func(body string, fileNodes []*graph.Node) schemaHints
}
// schemaEnrichers is the registry of body scanners. Each language
// contributes its own detectors below; the driver iterates over them
// and merges results. Declaration-site order is not significant:
// hints merge additively so two enrichers can contribute different
// fields without stepping on each other.
var schemaEnrichers []schemaEnricher
// EnrichHTTPContract is the exported entry point for the schema
// enrichment pipeline. The per-file HTTPExtractor calls it during
// extraction; the indexer's cross-file handler-resolution post-pass
// calls it again when the first attempt ran against the wrong
// function body (router vs. actual handler).
func EnrichHTTPContract(c *Contract, lines []string, fileNodes []*graph.Node, lang string) {
EnrichHTTPContractWithTree(c, lines, fileNodes, lang, nil)
}
// EnrichHTTPContractWithTree is the tree-aware variant: it runs the
// regex-based enricher first (so non-Go languages and patterns the
// AST doesn't recognise still produce meta), then overlays AST-derived
// facts when tree is non-nil and the language has a registered
// BodyFactsFactory. AST output wins on the keys it can confidently
// produce; regex output stays for the rest.
func EnrichHTTPContractWithTree(
c *Contract,
lines []string,
fileNodes []*graph.Node,
lang string,
tree *parser.ParseTree,
) {
enrichHTTPContract(c, lines, fileNodes, lang)
if tree != nil {
applyBodyFactsToHTTPContract(c, fileNodes, tree)
}
}
// enrichHTTPContract extracts schema-shape hints from the handler body
// that owns `c` and folds them into c.Meta. It is a no-op when the
// contract is a consumer (there is no handler to scan), when the
// handler's body span can't be located, or when no enricher matched.
//
// Keys written to c.Meta on success:
//
// path_params []string — always when the path template has placeholders
// query_params []string — when any enricher found query reads
// status_codes []int — when any enricher found status writes
// request_type string — symbol ID or bare type name
// request_expr string — raw expression fallback
// response_type string — symbol ID or bare type name
// response_expr string — raw expression fallback
// schema_source string — one of "extracted" | "partial" | "none"
func enrichHTTPContract(c *Contract, lines []string, fileNodes []*graph.Node, lang string) {
if c.Meta == nil {
c.Meta = map[string]any{}
}
// Path params can be read off the normalised path regardless of
// whether we ever find the handler body.
if path, _ := c.Meta["path"].(string); path != "" {
if pps := pathParamsFromTemplate(path); len(pps) > 0 {
c.Meta["path_params"] = pps
}
}
if c.Role != RoleProvider {
// Consumer-side schema extraction is handled by the consumer
// enrichers below, which look at the call site not a handler
// body. Keep the call short: find the call-site line and a
// small window around it.
enrichConsumerContract(c, lines, fileNodes, lang)
return
}
start, end := handlerBodyRange(c, fileNodes, lines)
if start <= 0 || end < start {
c.Meta["schema_source"] = "none"
return
}
body := strings.Join(lines[start-1:end], "\n")
var merged schemaHints
matched := false
for _, e := range schemaEnrichers {
if !containsStr(e.languages, lang) {
continue
}
if !containsRole(e.roles, c.Role) {
continue
}
h := e.detect(body, fileNodes)
if h.isEmpty() {
continue
}
matched = true
merged.merge(h)
}
applyHints(c, merged, matched)
}
// enrichConsumerContract handles the consumer case — we scan a small
// window around the call site for payload arg types, decode targets,
// and any JSON-encode wrapper expressions.
func enrichConsumerContract(c *Contract, lines []string, fileNodes []*graph.Node, lang string) {
// Prefer the enclosing function's body range when SymbolID
// resolves to a known function in this file. The wide
// callSiteWindow heuristic (±6/+14 lines) was designed for
// top-level/script call sites where there's no enclosing
// function — but it spans MULTIPLE neighbouring functions in
// dense object literals (api.health/api.tools/api.stats stacked
// 2-3 lines apart in api.ts), so the regex's first
// `Promise<X>` match latches onto the WRONG endpoint's type.
// The function-body window is tight, structurally correct, and
// always preferable when available.
start, end := consumerBodyRange(c, fileNodes, lines)
if start <= 0 {
c.Meta["schema_source"] = "none"
return
}
body := strings.Join(lines[start-1:end], "\n")
var merged schemaHints
matched := false
for _, e := range schemaEnrichers {
if !containsStr(e.languages, lang) {
continue
}
if !containsRole(e.roles, c.Role) {
continue
}
h := e.detect(body, fileNodes)
if h.isEmpty() {
continue
}
matched = true
merged.merge(h)
}
applyHints(c, merged, matched)
}
func containsRole(xs []Role, r Role) bool {
if len(xs) == 0 {
return true
}
for _, x := range xs {
if x == r {
return true
}
}
return false
}
func applyHints(c *Contract, h schemaHints, matched bool) {
if !matched {
c.Meta["schema_source"] = "none"
return
}
if h.RequestType != "" {
c.Meta["request_type"] = h.RequestType
}
if h.RequestExpr != "" {
c.Meta["request_expr"] = h.RequestExpr
}
if h.ResponseType != "" {
c.Meta["response_type"] = h.ResponseType
if h.ResponseRepeated {
c.Meta["response_repeated"] = true
}
}
if h.ResponseExpr != "" {
c.Meta["response_expr"] = h.ResponseExpr
}
if len(h.ResponseEnvelope) > 0 {
arr := make([]map[string]any, 0, len(h.ResponseEnvelope))
for _, f := range h.ResponseEnvelope {
row := map[string]any{"name": f.Name}
if f.Type != "" {
row["type"] = f.Type
}
if f.Expr != "" {
row["expr"] = f.Expr
}
if f.Repeated {
row["repeated"] = true
}
arr = append(arr, row)
}
c.Meta["response_envelope"] = arr
}
if qs := uniqStrings(h.QueryParams); len(qs) > 0 {
c.Meta["query_params"] = qs
}
if ss := uniqInts(h.StatusCodes); len(ss) > 0 {
c.Meta["status_codes"] = ss
}
// "extracted" means we have real type references on whichever side
// of the wire is relevant for this role. Everything else is
// partial — we saw something but couldn't pin a type.
haveTypes := false
if c.Role == RoleProvider {
haveTypes = h.RequestType != "" || h.ResponseType != ""
} else {
haveTypes = h.RequestType != "" || h.ResponseType != ""
}
if haveTypes {
c.Meta["schema_source"] = "extracted"
} else {
c.Meta["schema_source"] = "partial"
}
}
// handlerBodyRange returns the 1-based [start, end] line range covering
// the handler's body in the file. Returns (0, 0) when no sensible span
// can be located. For parsers that record end_line accurately (Go,
// Python) we trust it; otherwise we brace-balance up to a 200-line
// safety window starting from the symbol's start_line.
func handlerBodyRange(c *Contract, fileNodes []*graph.Node, lines []string) (int, int) {
// Find the handler node.
var node *graph.Node
for _, n := range fileNodes {
if n.ID == c.SymbolID {
node = n
break
}
}
if node == nil {
// Fall back to a window around the declaration line.
return braceWindow(lines, c.Line)
}
start := node.StartLine
end := node.EndLine
if start <= 0 {
start = c.Line
}
if end > start {
return start, clampLine(end, len(lines))
}
return braceWindow(lines, start)
}
// braceWindow is the fallback for parsers that only record start_line:
// walk from `from` forward up to 200 lines, counting braces, and stop
// when the first opening `{` on or after `from` closes. Useful for
// Dart and (historically) TypeScript where the extractor pins only the
// declaration line.
func braceWindow(lines []string, from int) (int, int) {
if from <= 0 || from > len(lines) {
return 0, 0
}
depth := 0
opened := false
end := from
limit := from + 200
if limit > len(lines) {
limit = len(lines)
}
for i := from - 1; i < limit; i++ {
line := lines[i]
for _, ch := range line {
if ch == '{' {
depth++
opened = true
} else if ch == '}' && opened {
depth--
}
}
end = i + 1
if opened && depth <= 0 {
return from, end
}
}
return from, end
}
// consumerBodyRange returns the best body window for a consumer
// contract. Order of preference:
// 1. The enclosing function node when SymbolID resolves — gives a
// tight, correct window even in dense object-literal API
// declarations where neighbouring methods sit 2-3 lines apart.
// 2. The wide ±6/+14 callSiteWindow heuristic, used when SymbolID
// doesn't resolve to a fileNode (top-level scripts, inline
// module code).
func consumerBodyRange(c *Contract, fileNodes []*graph.Node, lines []string) (int, int) {
if c.SymbolID != "" {
for _, n := range fileNodes {
if n.ID != c.SymbolID {
continue
}
// Only trust the function body window when the call line
// falls inside the function's range. Synthetic / stale
// SymbolIDs (legacy tests, contracts whose handler was
// re-resolved post-extract) point at the wrong function;
// in that case fall back to the wide call-site window.
if n.StartLine > 0 && n.EndLine >= n.StartLine &&
c.Line >= n.StartLine && c.Line <= n.EndLine {
return n.StartLine, clampLine(n.EndLine, len(lines))
}
break
}
}
return callSiteWindow(c, lines)
}
// callSiteWindow picks a small window of source around a consumer
// call site — enough to catch `jsonEncode(payload)` or `Decode(&resp)`
// patterns that sit in adjacent lines.
func callSiteWindow(c *Contract, lines []string) (int, int) {
if c.Line <= 0 || c.Line > len(lines) {
return 0, 0
}
start := c.Line - 6
if start < 1 {
start = 1
}
end := c.Line + 14
if end > len(lines) {
end = len(lines)
}
return start, end
}
func clampLine(n, max int) int {
if n < 1 {
return 1
}
if n > max {
return max
}
return n
}
// pathParamRe picks canonical {name} placeholders out of a normalised
// path. Other placeholder syntaxes are already collapsed to this form
// by NormalizeHTTPPath, so this is the only pattern we need here.
var pathParamRe = regexp.MustCompile(`\{([^}]+)\}`)
func pathParamsFromTemplate(path string) []string {
ms := pathParamRe.FindAllStringSubmatch(path, -1)
if len(ms) == 0 {
return nil
}
out := make([]string, 0, len(ms))
for _, m := range ms {
out = append(out, m[1])
}
return uniqStrings(out)
}
// -----------------------------------------------------------------------------
// Shared helpers for enricher implementations
// -----------------------------------------------------------------------------
// resolveTypeInFile upgrades a bare type name to its in-file symbol ID
// when a matching type node is present. Returns the original name
// unchanged when nothing matches — a later module-wide pass can
// upgrade it using the import graph.
func resolveTypeInFile(name string, fileNodes []*graph.Node) string {
name = strings.TrimSpace(strings.TrimPrefix(name, "*"))
if name == "" {
return ""
}
// Drop package qualifier for in-file lookup: "common.Foo" -> "Foo".
if idx := strings.LastIndex(name, "."); idx >= 0 {
name = name[idx+1:]
}
// Also accept KindInterface — TypeScript / Java / Kotlin model
// type definitions as `interface Foo {}`, which gets emitted as
// KindInterface, not KindType. Limiting to KindType silently
// drops every TS interface response_type lookup.
for _, n := range fileNodes {
if n.Name != name {
continue
}
if n.Kind == graph.KindType || n.Kind == graph.KindInterface {
return n.ID
}
}
return name // bare name; upgraded later
}
func uniqStrings(in []string) []string {
if len(in) == 0 {
return nil
}
seen := make(map[string]struct{}, len(in))
out := make([]string, 0, len(in))
for _, s := range in {
if s == "" {
continue
}
if _, ok := seen[s]; ok {
continue
}
seen[s] = struct{}{}
out = append(out, s)
}
sort.Strings(out)
return out
}
func uniqInts(in []int) []int {
if len(in) == 0 {
return nil
}
seen := make(map[int]struct{}, len(in))
out := make([]int, 0, len(in))
for _, v := range in {
if _, ok := seen[v]; ok {
continue
}
seen[v] = struct{}{}
out = append(out, v)
}
sort.Ints(out)
return out
}
func containsStr(xs []string, needle string) bool {
if len(xs) == 0 {
return true // empty = match any
}
for _, x := range xs {
if x == needle {
return true
}
}
return false
}
// -----------------------------------------------------------------------------
// Status-code name → int table for Go's net/http constants. Any
// framework that uses the same constants (most Go ones do) gets this
// for free.
// -----------------------------------------------------------------------------
var httpStatusNames = map[string]int{
"StatusContinue": 100,
"StatusSwitchingProtocols": 101,
"StatusOK": 200,
"StatusCreated": 201,
"StatusAccepted": 202,
"StatusNoContent": 204,
"StatusMovedPermanently": 301,
"StatusFound": 302,
"StatusSeeOther": 303,
"StatusNotModified": 304,
"StatusTemporaryRedirect": 307,
"StatusPermanentRedirect": 308,
"StatusBadRequest": 400,
"StatusUnauthorized": 401,
"StatusForbidden": 403,
"StatusNotFound": 404,
"StatusMethodNotAllowed": 405,
"StatusConflict": 409,
"StatusGone": 410,
"StatusPreconditionFailed": 412,
"StatusUnprocessableEntity": 422,
"StatusTooManyRequests": 429,
"StatusInternalServerError": 500,
"StatusNotImplemented": 501,
"StatusBadGateway": 502,
"StatusServiceUnavailable": 503,
"StatusGatewayTimeout": 504,
}
// parseStatusExpr turns a status expression like "http.StatusOK" or
// "200" into an int, ignoring anything it doesn't recognise.
func parseStatusExpr(expr string) (int, bool) {
expr = strings.TrimSpace(expr)
if n, err := strconv.Atoi(expr); err == nil {
return n, true
}
if strings.HasPrefix(expr, "http.") {
name := strings.TrimPrefix(expr, "http.")
if v, ok := httpStatusNames[name]; ok {
return v, true
}
}
// Bare constant: "StatusOK"
if v, ok := httpStatusNames[expr]; ok {
return v, true
}
return 0, false
}