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

454 lines
13 KiB
Go

package contracts
import (
"strings"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/parser"
)
// applyBodyFactsToHTTPContract overrides Meta keys with AST-derived
// facts when they're more authoritative than the regex enricher's
// output. Called after enrichHTTPContract has run; AST output wins
// on the keys it can confidently produce.
//
// Phase 1 invariant: this function may not regress an existing
// Meta value to a strictly less informative one — i.e. if the
// regex pass set response_type but the AST can't, leave the regex
// value alone. The AST only writes when it has a confident answer.
//
// Both provider and consumer contracts are handled. The semantics
// differ by Role:
// - Provider: WriteJSON/Encode → response_type; Decode/BodyParser/
// ShouldBind*/Unmarshal → request_type
// - Consumer: Marshal/MarshalIndent → request_type;
// Decode/Unmarshal → response_type (response from server is
// decoded into a typed variable)
func applyBodyFactsToHTTPContract(c *Contract, fileNodes []*graph.Node, tree *parser.ParseTree) {
if c == nil || tree == nil {
return
}
handler := findContractHandlerNode(c, fileNodes)
if handler == nil {
return
}
bf := MakeBodyFacts(tree, handler)
if _, isNop := bf.(nopBodyFacts); isNop {
return
}
switch c.Role {
case RoleProvider:
applyProviderFacts(c, fileNodes, bf)
case RoleConsumer:
applyConsumerFacts(c, fileNodes, bf)
}
}
// applyProviderFacts handles the server-side enrichment.
func applyProviderFacts(c *Contract, fileNodes []*graph.Node, bf BodyFacts) {
if writes := bf.StatusWrites(); len(writes) > 0 {
mergeIntStrings(c.Meta, "status_codes", writes)
}
if reads := bf.QueryReads(); len(reads) > 0 {
mergeStringList(c.Meta, "query_params", reads)
}
// Request type: take the first non-Marshal request binding
// (Marshal is consumer-side, see applyConsumerFacts).
for _, rb := range bf.RequestBindings() {
if rb.Helper == "Marshal" || rb.Helper == "MarshalIndent" {
continue
}
applyRequestBindingToContract(c, fileNodes, bf, rb)
break
}
calls := bf.ResponseCalls()
if len(calls) == 0 {
return
}
rc := lastSuccessResponseCall(calls)
applyResponseCallToContract(c, fileNodes, bf, rc)
}
// applyConsumerFacts handles the client-side enrichment.
// - Marshal(req) → request_type from VarBinding(req).TypeID
// - Decode(&out) → response_type from VarBinding(out).TypeID
// - Unmarshal(b,&out) → response_type from VarBinding(out).TypeID
// - var anonymous composite (Marshal(&Req{}))
// → request_type from CompositeType
func applyConsumerFacts(c *Contract, fileNodes []*graph.Node, bf BodyFacts) {
for _, rb := range bf.RequestBindings() {
switch rb.Helper {
case "Marshal", "MarshalIndent":
applyConsumerBinding(c, fileNodes, bf, rb, "request")
case "Decode", "Unmarshal":
applyConsumerBinding(c, fileNodes, bf, rb, "response")
}
}
}
// applyConsumerBinding stamps either request_type or response_type
// (per kind) from a binding's variable lookup.
func applyConsumerBinding(c *Contract, fileNodes []*graph.Node, bf BodyFacts, rb RequestBinding, kind string) {
typeKey := "request_type"
exprKey := "request_expr"
if kind == "response" {
typeKey = "response_type"
exprKey = "response_expr"
}
if existing, _ := c.Meta[typeKey].(string); existing != "" {
return
}
resolved := ""
repeated := false
switch {
case rb.CompositeType != "":
resolved = resolveTypeInFile(rb.CompositeType, fileNodes)
case rb.VarName != "":
b := bf.VarBinding(rb.VarName)
if b.TypeID != "" {
resolved = resolveTypeInFile(b.TypeID, fileNodes)
repeated = b.Repeated
}
}
if resolved == "" {
return
}
c.Meta[typeKey] = resolved
if repeated {
repeatedKey := "request_repeated"
if kind == "response" {
repeatedKey = "response_repeated"
}
c.Meta[repeatedKey] = true
}
c.Meta["schema_source"] = "extracted"
delete(c.Meta, exprKey)
}
// lastSuccessResponseCall picks the response call that most likely
// represents the success path. Heuristic: the last call whose status
// is 2xx or unknown; falls back to the last call.
func lastSuccessResponseCall(calls []ResponseCall) ResponseCall {
for i := len(calls) - 1; i >= 0; i-- {
c := calls[i]
if !c.StatusKnown {
return c
}
if c.StatusCode >= 200 && c.StatusCode < 300 {
return c
}
}
return calls[len(calls)-1]
}
// applyResponseCallToContract turns one ResponseCall's value
// argument into Meta updates: response_type, response_envelope,
// response_repeated, schema_source.
func applyResponseCallToContract(c *Contract, fileNodes []*graph.Node, bf BodyFacts, rc ResponseCall) {
if rc.ValueArg == nil {
return
}
switch rc.ValueArg.Kind() {
case "composite_literal":
applyCompositeResponse(c, fileNodes, bf, rc.ValueArg)
case "identifier":
applyIdentResponse(c, fileNodes, bf, rc.ValueExpr)
case "interpreted_string_literal", "raw_string_literal":
applyLiteralResponse(c, "string")
case "int_literal":
applyLiteralResponse(c, "int")
case "float_literal":
applyLiteralResponse(c, "float64")
case "true", "false":
applyLiteralResponse(c, "bool")
case "unary_expression":
// `&Foo{...}` or `&value` — unwrap.
inner := unaryOperand(rc.ValueArg)
if inner == nil {
return
}
switch inner.Kind() {
case "composite_literal":
applyCompositeResponse(c, fileNodes, bf, inner)
case "identifier":
applyIdentResponse(c, fileNodes, bf, inner.Text())
}
}
}
// applyLiteralResponse stamps a primitive response_type when the
// helper's value argument is a string / int / float / bool literal.
func applyLiteralResponse(c *Contract, typeName string) {
c.Meta["response_type"] = typeName
c.Meta["schema_source"] = "extracted"
delete(c.Meta, "response_expr")
}
// applyRequestBindingToContract stamps request_type from one request
// binding. Resolution order:
// 1. CompositeType (Decode(&Foo{})) — direct, no var lookup needed
// 2. VarBinding(VarName).TypeID — chase the variable to its declared
// type (e.g. `var req CreateRequest; Decode(&req)` → CreateRequest)
// 3. Leave c.Meta alone (regex pass might have set it, or it stays
// unset)
func applyRequestBindingToContract(c *Contract, fileNodes []*graph.Node, bf BodyFacts, rb RequestBinding) {
if rb.CompositeType != "" {
c.Meta["request_type"] = resolveTypeInFile(rb.CompositeType, fileNodes)
c.Meta["schema_source"] = "extracted"
delete(c.Meta, "request_expr")
return
}
if rb.VarName == "" {
return
}
b := bf.VarBinding(rb.VarName)
if b.TypeID == "" {
return
}
c.Meta["request_type"] = resolveTypeInFile(b.TypeID, fileNodes)
c.Meta["schema_source"] = "extracted"
delete(c.Meta, "request_expr")
}
// applyCompositeResponse handles `WriteJSON(w, code, Foo{...})` and
// `WriteJSON(w, code, map[string]any{...})`. For map composites we
// walk the entries into an envelope.
func applyCompositeResponse(c *Contract, fileNodes []*graph.Node, bf BodyFacts, valueNode *Node) {
if valueNode == nil || valueNode.Inner() == nil {
return
}
typ := compositeTypeNode(valueNode.Inner())
if typ == nil {
return
}
switch typ.Type() {
case "map_type":
entries := bf.MapLiteralEntries(valueNode)
if len(entries) == 0 {
return
}
env := buildEnvelopeFromEntries(bf, fileNodes, entries)
if len(env) == 0 {
return
}
c.Meta["response_envelope"] = env
if len(env) == 1 {
if t, _ := env[0]["type"].(string); t != "" {
c.Meta["response_type"] = t
}
if r, _ := env[0]["repeated"].(bool); r {
c.Meta["response_repeated"] = true
}
}
c.Meta["schema_source"] = "extracted"
delete(c.Meta, "response_expr")
case "slice_type", "array_type":
// `[]Foo{...}` — record the element type as response, marked
// repeated. Walk the slice element via bindingFromComposite.
if goBF, ok := bf.(*goBodyFacts); ok {
b := goBF.bindingFromComposite(valueNode.Inner())
if b.TypeID != "" {
c.Meta["response_type"] = resolveTypeInFile(b.TypeID, fileNodes)
if b.Repeated {
c.Meta["response_repeated"] = true
}
c.Meta["schema_source"] = "extracted"
delete(c.Meta, "response_expr")
}
}
default:
// Named type: `WriteJSON(w, code, Foo{...})` → response = Foo.
if goBF, ok := bf.(*goBodyFacts); ok {
b := goBF.bindingFromComposite(valueNode.Inner())
if b.TypeID != "" {
c.Meta["response_type"] = resolveTypeInFile(b.TypeID, fileNodes)
c.Meta["schema_source"] = "extracted"
delete(c.Meta, "response_expr")
}
}
}
}
// applyIdentResponse handles `WriteJSON(w, code, repos)`. Looks up
// repos's binding; if it resolves to a known type or a method call,
// stamps the response_type or leaves a clean response_expr for the
// post-pass to trace.
func applyIdentResponse(c *Contract, fileNodes []*graph.Node, bf BodyFacts, ident string) {
ident = strings.TrimSpace(ident)
if ident == "" {
return
}
b := bf.VarBinding(ident)
switch b.Kind {
case BindingComposite, BindingSliceLit, BindingMakeSlice, BindingMakeMap:
if b.TypeID != "" {
c.Meta["response_type"] = resolveTypeInFile(b.TypeID, fileNodes)
if b.Repeated {
c.Meta["response_repeated"] = true
}
c.Meta["schema_source"] = "extracted"
delete(c.Meta, "response_expr")
return
}
case BindingStringLit, BindingIntLit, BindingFloatLit, BindingBoolLit,
BindingPathValue, BindingFormValue, BindingHeaderValue, BindingQueryGet:
c.Meta["response_type"] = b.TypeID
c.Meta["schema_source"] = "extracted"
delete(c.Meta, "response_expr")
return
case BindingMethodCall, BindingFuncCall:
// Defer to the post-pass (resolveCallReturnTypes) which will
// walk the graph from b.CallExpr to the method's return
// type. We just clean up response_expr to be the bare ident
// so the post-pass's `isLikelyIdentifier` branch matches.
if rt, _ := c.Meta["response_type"].(string); rt == "" {
c.Meta["response_expr"] = ident
}
}
}
// buildEnvelopeFromEntries converts AST map-literal entries into the
// `[]map[string]any` shape the dashboard renders. Each entry's value
// expression is matched against the body's bindings to resolve a
// type; when no fact is available the row stays with just an `expr`
// field for the post-pass to chase.
func buildEnvelopeFromEntries(bf BodyFacts, fileNodes []*graph.Node, entries []KeyValue) []map[string]any {
out := make([]map[string]any, 0, len(entries))
for _, e := range entries {
row := map[string]any{"name": e.Key, "expr": e.ValueExpr}
// Direct composite literals carry their type inline.
if e.ValueNode != nil && e.ValueNode.Kind() == "composite_literal" {
if goBF, ok := bf.(*goBodyFacts); ok {
b := goBF.bindingFromComposite(e.ValueNode.Inner())
if b.TypeID != "" {
row["type"] = resolveTypeInFile(b.TypeID, fileNodes)
if b.Repeated {
row["repeated"] = true
}
}
}
} else {
// Bare identifier: chase its binding.
ident := strings.TrimLeft(e.ValueExpr, "&*")
if isPlainIdent(ident) {
b := bf.VarBinding(ident)
if b.TypeID != "" {
row["type"] = resolveTypeInFile(b.TypeID, fileNodes)
}
if b.Repeated {
row["repeated"] = true
}
}
}
out = append(out, row)
}
return out
}
// findContractHandlerNode locates the handler graph node referenced
// by a contract's SymbolID.
func findContractHandlerNode(c *Contract, fileNodes []*graph.Node) *graph.Node {
for _, n := range fileNodes {
if n.ID == c.SymbolID {
return n
}
}
return nil
}
// unaryOperand returns the operand of a unary_expression Node.
func unaryOperand(n *Node) *Node {
if n == nil || n.Inner() == nil {
return nil
}
op := n.Inner().ChildByFieldName("operand")
if op == nil {
return nil
}
return NewNode(op, sourceOf(n))
}
// sourceOf retrieves the source bytes a Node was built with. Internal
// helper that reaches into the Node struct.
func sourceOf(n *Node) []byte {
if n == nil {
return nil
}
return n.src
}
// mergeIntStrings adds new ints into an existing []int meta field
// (or creates it) without duplicates. Preserves source order.
func mergeIntStrings(meta map[string]any, key string, additions []int) {
if meta == nil {
return
}
seen := map[int]bool{}
out := []int{}
if existing, ok := meta[key].([]int); ok {
for _, v := range existing {
if !seen[v] {
seen[v] = true
out = append(out, v)
}
}
}
for _, v := range additions {
if !seen[v] {
seen[v] = true
out = append(out, v)
}
}
if len(out) > 0 {
meta[key] = out
}
}
// mergeStringList adds strings to a []string Meta field, dedup,
// preserving source order.
func mergeStringList(meta map[string]any, key string, additions []string) {
if meta == nil {
return
}
seen := map[string]bool{}
out := []string{}
if existing, ok := meta[key].([]string); ok {
for _, v := range existing {
if !seen[v] {
seen[v] = true
out = append(out, v)
}
}
}
for _, v := range additions {
if v == "" || seen[v] {
continue
}
seen[v] = true
out = append(out, v)
}
if len(out) > 0 {
meta[key] = out
}
}
// isPlainIdent reports whether s is a simple Go identifier (no dots,
// brackets, parens). Used to gate the var-binding lookup so a value
// expression like `len(repos)` isn't sent to BodyFacts.VarBinding.
func isPlainIdent(s string) bool {
if s == "" {
return false
}
for i, r := range s {
if r == '_' ||
(r >= 'a' && r <= 'z') ||
(r >= 'A' && r <= 'Z') ||
(i > 0 && r >= '0' && r <= '9') {
continue
}
return false
}
return true
}