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
225 lines
8.0 KiB
Go
225 lines
8.0 KiB
Go
package contracts
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/zzet/gortex/internal/parser"
|
|
|
|
"github.com/zzet/gortex/internal/graph"
|
|
)
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Configurable HTTP-client wrapper aliases
|
|
// -----------------------------------------------------------------------------
|
|
//
|
|
// Many TS/JS codebases route every network call through a project-local
|
|
// helper — `apiGet('/users')`, `apiPost('/orders', body)`,
|
|
// `httpClient.request('GET', '/users')`. The built-in fetch/axios
|
|
// consumer patterns never see these because the call target is a
|
|
// user-defined function, so the endpoints stay invisible to the
|
|
// contract matcher. The `index.http_client_aliases` config lists those
|
|
// wrapper names; this pass mints a consumer contract for each call to
|
|
// one of them, using the SAME `http::METHOD::/path` ID scheme the
|
|
// matcher pairs against providers.
|
|
//
|
|
// Supported call shapes (alias = a configured name like `apiGet` or
|
|
// `client.request`):
|
|
//
|
|
// apiGet('/users') → http::GET::/users
|
|
// apiPost('/orders', body) → http::POST::/orders
|
|
// apiDelete(`/users/${id}`) → http::DELETE::/users/{p1}
|
|
// (method from the alias name's verb suffix: get/post/put/delete/
|
|
// patch/head/options, case-insensitive)
|
|
//
|
|
// apiCall('GET', '/users') → http::GET::/users
|
|
// client.request('POST', '/orders') → http::POST::/orders
|
|
// (alias name carries no verb suffix → the first string literal is
|
|
// read as the method when it is a bare HTTP verb, and the next
|
|
// string literal is the path)
|
|
//
|
|
// When the alias name has no verb suffix and the first literal isn't an
|
|
// HTTP verb, the method defaults to GET and the first string literal is
|
|
// the path — matching the lenient default the built-in `fetch(url)`
|
|
// pattern uses.
|
|
|
|
// srcMentionsAnyAlias reports whether src textually mentions any
|
|
// configured alias name (the bare tail of a dotted alias counts, so
|
|
// `client.request` is detected via `request`). A cheap substring gate
|
|
// that lets the prefilter keep an alias-only file alive without
|
|
// widening the regex scan for everyone else.
|
|
func srcMentionsAnyAlias(src []byte, aliases []string) bool {
|
|
for _, a := range aliases {
|
|
a = strings.TrimSpace(a)
|
|
if a == "" {
|
|
continue
|
|
}
|
|
if i := strings.LastIndex(a, "."); i >= 0 {
|
|
a = a[i+1:]
|
|
}
|
|
if a != "" && bytes.Contains(src, []byte(a)) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// aliasVerbSuffixRe pulls a trailing HTTP verb off an alias name so
|
|
// `apiGet` → GET, `fetchUsersPost` → POST. Anchored to the end and
|
|
// case-insensitive; the verb must be the literal tail of the name.
|
|
var aliasVerbSuffixRe = regexp.MustCompile(`(?i)(get|post|put|delete|patch|head|options)$`)
|
|
|
|
// aliasMethodLiteralRe matches a bare HTTP verb string literal used as
|
|
// the first argument of a generic alias like apiCall('GET', '/x').
|
|
var aliasMethodLiteralRe = regexp.MustCompile(`(?i)^(get|post|put|delete|patch|head|options)$`)
|
|
|
|
// detectClientAliasConsumers scans src for calls to any configured
|
|
// client-alias function and returns one consumer contract per call.
|
|
// The path is the first path-shaped string literal in the argument
|
|
// list; the method comes from the alias name's verb suffix or, for
|
|
// suffix-less aliases, a leading bare-verb literal argument.
|
|
func (h *HTTPExtractor) detectClientAliasConsumers(
|
|
filePath string,
|
|
text string,
|
|
lines []string,
|
|
fileNodes []*graph.Node,
|
|
lang string,
|
|
tree *parser.ParseTree,
|
|
) []Contract {
|
|
var out []Contract
|
|
seen := make(map[string]bool) // de-dupe (contractID, line) within the file
|
|
|
|
for _, alias := range h.ClientAliases {
|
|
alias = strings.TrimSpace(alias)
|
|
if alias == "" {
|
|
continue
|
|
}
|
|
re := aliasCallRegex(alias)
|
|
if re == nil {
|
|
continue
|
|
}
|
|
// The verb suffix is derived from the LAST dotted segment of
|
|
// the alias so `client.get` → GET while `client.request` →
|
|
// (none, generic two-arg form).
|
|
nameForVerb := alias
|
|
if i := strings.LastIndex(alias, "."); i >= 0 {
|
|
nameForVerb = alias[i+1:]
|
|
}
|
|
method := ""
|
|
if m := aliasVerbSuffixRe.FindString(nameForVerb); m != "" {
|
|
method = strings.ToUpper(m)
|
|
}
|
|
|
|
for _, m := range re.FindAllStringSubmatchIndex(text, -1) {
|
|
// m[2]:m[3] is the captured argument-list head (everything
|
|
// from the opening paren to a reasonable bound).
|
|
if len(m) < 4 || m[2] < 0 {
|
|
continue
|
|
}
|
|
args := text[m[2]:m[3]]
|
|
method, path, ok := aliasMethodAndPath(method, args)
|
|
if !ok {
|
|
continue
|
|
}
|
|
normPath, origNames := NormalizeHTTPPathWithParams(path)
|
|
contractID := fmt.Sprintf("http::%s::%s", method, normPath)
|
|
|
|
lineNum := lineAtOffset(lines, m[0])
|
|
dedupeKey := fmt.Sprintf("%s@%d", contractID, lineNum)
|
|
if seen[dedupeKey] {
|
|
continue
|
|
}
|
|
seen[dedupeKey] = true
|
|
|
|
meta := map[string]any{
|
|
"method": method,
|
|
"path": normPath,
|
|
"framework": "client-alias",
|
|
"alias": alias,
|
|
}
|
|
if len(origNames) > 0 {
|
|
meta["path_param_names"] = origNames
|
|
}
|
|
|
|
c := Contract{
|
|
ID: contractID,
|
|
Type: ContractHTTP,
|
|
Role: RoleConsumer,
|
|
SymbolID: findEnclosingSymbol(fileNodes, lineNum),
|
|
FilePath: filePath,
|
|
Line: lineNum,
|
|
Meta: meta,
|
|
Confidence: 0.85,
|
|
}
|
|
// Enrich request/response types from the call-site window
|
|
// just like a regex-detected consumer would.
|
|
EnrichHTTPContractWithTree(&c, lines, fileNodes, lang, tree)
|
|
|
|
out = append(out, c)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// aliasCallRegex compiles a matcher for a single alias name. The
|
|
// capture group is the argument-list head: from the char after the
|
|
// opening paren up to (but not including) the matching close paren or
|
|
// the end of the line, bounded so a missing close paren can't run
|
|
// away. Dotted aliases (`client.get`) match the literal receiver.method
|
|
// form; bare aliases match a free-standing call, optionally preceded by
|
|
// a non-identifier char so `myApiGet(` doesn't match alias `apiGet`.
|
|
func aliasCallRegex(alias string) *regexp.Regexp {
|
|
// Escape each dotted segment, join with an escaped dot.
|
|
segs := strings.Split(alias, ".")
|
|
for i, s := range segs {
|
|
segs[i] = regexp.QuoteMeta(s)
|
|
}
|
|
pat := strings.Join(segs, `\.`)
|
|
// `(?:^|[^\w$.])` ensures we match a whole identifier — not a
|
|
// suffix of a longer name (apiGet ≠ myApiGet) and not a property
|
|
// access chain we didn't ask for. The capture stops at the first
|
|
// `)` or newline; nested parens inside an argument (rare for a
|
|
// path literal) would truncate the capture, which only loses the
|
|
// trailing args we don't read anyway.
|
|
full := `(?:^|[^\w$.])` + pat + `\(([^)\n]*)`
|
|
re, err := regexp.Compile(full)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return re
|
|
}
|
|
|
|
// aliasStringLitRe captures consecutive string literals (single, double,
|
|
// or backtick) from an argument-list head, in source order.
|
|
var aliasStringLitRe = regexp.MustCompile("[\"'`]([^\"'`]*)[\"'`]")
|
|
|
|
// aliasMethodAndPath resolves the (method, path) pair for one alias
|
|
// call. nameMethod is the verb derived from the alias name (empty when
|
|
// the name has no verb suffix). args is the captured argument-list head.
|
|
//
|
|
// - nameMethod set: the first string literal is the path.
|
|
// - nameMethod empty: if the first literal is a bare HTTP verb it is
|
|
// the method and the second literal is the path
|
|
// (apiCall('GET', '/x')); otherwise method defaults to GET and the
|
|
// first literal is the path.
|
|
//
|
|
// Returns ok=false when no usable path literal is present (e.g. the
|
|
// argument is a runtime variable, not a string literal).
|
|
func aliasMethodAndPath(nameMethod, args string) (method, path string, ok bool) {
|
|
lits := aliasStringLitRe.FindAllStringSubmatch(args, -1)
|
|
if len(lits) == 0 {
|
|
return "", "", false
|
|
}
|
|
if nameMethod != "" {
|
|
return nameMethod, lits[0][1], true
|
|
}
|
|
// Suffix-less alias: try the (method, path) two-arg form.
|
|
if len(lits) >= 2 && aliasMethodLiteralRe.MatchString(strings.TrimSpace(lits[0][1])) {
|
|
return strings.ToUpper(strings.TrimSpace(lits[0][1])), lits[1][1], true
|
|
}
|
|
// Lenient default — first literal is the path, method GET.
|
|
return "GET", lits[0][1], true
|
|
}
|