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

238 lines
7.8 KiB
Go

package contracts
import (
"fmt"
"regexp"
"strings"
"github.com/zzet/gortex/internal/graph"
)
// SourceReader returns the on-disk source bytes for a caller node, or
// ok=false if unavailable. The contracts package is language-agnostic
// and doesn't know repo roots; the caller (indexer.MultiIndexer) builds
// a closure that maps a graph node to the file on disk by consulting
// repo metadata.
type SourceReader func(n *graph.Node) ([]byte, bool)
// InlineWrappers identifies HTTP-client wrapper functions (generic
// helpers that forward a path argument to fetch/http.Get/etc.) and
// emits per-caller consumer contracts with the caller's specific path.
// Without this, a codebase that routes every endpoint through a single
// request(path, ...) helper produces one useless parametric contract
// per wrapper and zero matches against real provider routes.
//
// Algorithm (BFS propagation across the wrapper chain):
// 1. Seed: every existing consumer HTTP contract whose normalized path
// is pathologically parametric ("/{word}") is a wrapper.
// 2. For each wrapper, walk graph.GetInEdges(symbol) with Kind=EdgeCalls
// — the functions that call this wrapper. For each, re-read the
// caller's source at the call-site line and extract the first arg.
// - Literal path → emit a new consumer contract for the caller.
// - Bare identifier matching the caller's own parameter name → the
// caller is itself a wrapper; enqueue it for the next pass.
// - Anything else (runtime expression) → skip silently.
// 3. Repeat until no new wrappers are found, bounded by a safety cap.
//
// Returns the set of contracts added (so callers can persist them into
// their per-repo registries — the transient merged registry MultiIndexer
// hands in is rebuilt on every ReconcileContractEdges call, so mutations
// to it don't survive between invocations).
func InlineWrappers(reg *Registry, g graph.Store, read SourceReader) []Contract {
if reg == nil || g == nil || read == nil {
return nil
}
wrappers := seedWrappers(reg)
seen := make(map[string]bool, len(wrappers))
for _, w := range wrappers {
seen[w.SymbolID] = true
}
var added []Contract
// Safety cap against pathological chains.
const maxPasses = 8
for pass := 0; pass < maxPasses && len(wrappers) > 0; pass++ {
var next []wrapperInfo
for _, w := range wrappers {
for _, edge := range g.GetInEdges(w.SymbolID) {
if edge.Kind != graph.EdgeCalls {
continue
}
caller := g.GetNode(edge.From)
if caller == nil {
continue
}
wrapperNode := g.GetNode(w.SymbolID)
if wrapperNode == nil {
continue
}
src, ok := read(caller)
if !ok {
continue
}
arg := extractFirstCallArg(src, edge.Line, wrapperNode.Name, caller.Language)
switch arg.Kind {
case argLiteral:
method := arg.Method
if method == "" {
method = "GET"
}
path, origNames := NormalizeHTTPPathWithParams(arg.Value)
meta := map[string]any{
"method": method,
"path": path,
"framework": "inlined-wrapper",
"wrapper": w.SymbolID,
}
if len(origNames) > 0 {
meta["path_param_names"] = origNames
}
c := Contract{
ID: fmt.Sprintf("http::%s::%s", method, path),
Type: ContractHTTP,
Role: RoleConsumer,
SymbolID: caller.ID,
FilePath: caller.FilePath,
Line: edge.Line,
RepoPrefix: caller.RepoPrefix,
// Workspace/project boundary slugs flow from the
// caller's graph node — stamped at index time.
// Without this carry-over the inlined contract
// gets the default workspace = repoPrefix and the
// matcher can't pair it with a same-workspace
// provider.
WorkspaceID: caller.WorkspaceID,
ProjectID: caller.ProjectID,
Meta: meta,
Confidence: 0.8,
}
// Run schema enrichment on the caller's body so
// the inlined contract carries request_type /
// response_type / query_params just like a
// regex-detected consumer contract would. Without
// this the dashboard shows "not declared on this
// side" for every wrapper-routed call site.
enrichInlinedWrapperContract(&c, g, caller, src)
reg.Add(c)
commitInlinedContractToGraph(g, c)
added = append(added, c)
case argBareParam:
if !seen[caller.ID] {
seen[caller.ID] = true
next = append(next, wrapperInfo{SymbolID: caller.ID})
}
}
}
}
wrappers = next
}
return added
}
// wrapperInfo is the minimal record carried through BFS passes.
type wrapperInfo struct {
SymbolID string
}
// enrichInlinedWrapperContract runs the schema enrichment pipeline
// against the caller's body so inlined wrapper consumer contracts
// carry the same request_type / response_type / query_params facts
// that natively-detected consumer contracts do.
//
// Mirrors the enrichment chain HTTPExtractor.extract runs after
// matching a regex pattern: lines + fileNodes + lang + tree feed
// EnrichHTTPContractWithTree, which dispatches to the per-language
// schema_enrich_*.go detectors and (for Go) the AST overlay.
func enrichInlinedWrapperContract(c *Contract, g graph.Store, caller *graph.Node, src []byte) {
if c == nil || caller == nil || len(src) == 0 {
return
}
lang := caller.Language
if lang == "" {
return
}
lines := strings.Split(string(src), "\n")
fileNodes := g.GetFileNodes(caller.FilePath)
tree := ParseTreeForLang(lang, src)
defer tree.Release()
EnrichHTTPContractWithTree(c, lines, fileNodes, lang, tree)
}
// seedWrappers finds the initial set of wrappers: consumer HTTP
// contracts whose normalized path is a single parameter placeholder
// like "/{path}" or "/{url}". Those shapes come from HTTPExtractor
// detecting fetch(`${API_URL}${path}`) — the classic signature of a
// fully-parametric wrapper URL.
func seedWrappers(reg *Registry) []wrapperInfo {
var out []wrapperInfo
for _, c := range reg.All() {
if c.Type != ContractHTTP || c.Role != RoleConsumer || c.SymbolID == "" {
continue
}
path, _ := c.Meta["path"].(string)
if !isWrapperPath(path) {
continue
}
out = append(out, wrapperInfo{SymbolID: c.SymbolID})
}
return out
}
// wrapperPathRE matches a normalized path that consists solely of one
// placeholder segment — the signature of a fully-parametric wrapper URL.
var wrapperPathRE = regexp.MustCompile(`^/\{?[a-zA-Z][a-zA-Z0-9_]*\}?$`)
func isWrapperPath(path string) bool {
return wrapperPathRE.MatchString(path)
}
// commitInlinedContractToGraph adds the contract as a graph node (if not
// already present) and a symbol → contract EdgeConsumes edge (also
// idempotent). Mirrors the commitContracts logic in the indexer but
// runs at wrapper-inline time so late-emitted contracts appear in
// contracts list output and in the matcher's graph view. Idempotency
// matters because ReconcileContractEdges runs on every repo change —
// without it each track/index would duplicate edges.
func commitInlinedContractToGraph(g graph.Store, c Contract) {
if g == nil {
return
}
if g.GetNode(c.ID) == nil {
g.AddNode(&graph.Node{
ID: c.ID,
Kind: graph.KindContract,
Name: c.ID,
FilePath: c.FilePath,
Language: "contract",
RepoPrefix: c.RepoPrefix,
WorkspaceID: c.EffectiveWorkspace(),
ProjectID: c.EffectiveProject(),
Meta: map[string]any{
"type": string(c.Type),
"role": string(c.Role),
"symbol_id": c.SymbolID,
"line": c.Line,
"confidence": c.Confidence,
"contract_meta": c.Meta,
},
})
}
if c.SymbolID == "" {
return
}
for _, existing := range g.GetOutEdges(c.SymbolID) {
if existing.Kind == graph.EdgeConsumes && existing.To == c.ID {
return
}
}
g.AddEdge(&graph.Edge{
From: c.SymbolID,
To: c.ID,
Kind: graph.EdgeConsumes,
FilePath: c.FilePath,
Line: c.Line,
})
}