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
303 lines
8.5 KiB
Go
303 lines
8.5 KiB
Go
package contracts
|
|
|
|
import (
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
// argKind classifies the first argument passed to a wrapper call.
|
|
type argKind int
|
|
|
|
const (
|
|
argUnknown argKind = iota // non-literal, non-identifier (runtime expression)
|
|
argLiteral // string/template literal — the caller's specific path
|
|
argBareParam // bare identifier (likely the caller's own path parameter)
|
|
)
|
|
|
|
// extractedArg describes the first argument at a wrapper call site
|
|
// plus any HTTP method found on a later argument's method: property.
|
|
type extractedArg struct {
|
|
Kind argKind
|
|
Value string // populated for argLiteral
|
|
Method string // e.g. "POST"; empty means "unknown / default GET"
|
|
}
|
|
|
|
// extractFirstCallArg finds the call expression at the given 1-based
|
|
// line in src and returns the first argument's classification plus any
|
|
// method: property from a later argument. wrapperName is the short
|
|
// name of the function being called (e.g. "request"). lang is the
|
|
// caller's language ("typescript", "javascript", "go", "dart").
|
|
//
|
|
// The strategy is the same across languages because call syntax is
|
|
// uniform enough in practice: find wrapperName(, scan forward until
|
|
// the matching close-paren while tracking paren depth and string
|
|
// escapes, then parse the captured arg list. Works for:
|
|
//
|
|
// request('/v1/users', getToken, { method: 'POST' })
|
|
// request(`/v1/users/${id}`, getToken)
|
|
// doFetch(path, token, options)
|
|
// request("/v1/users", getToken, map[string]any{"method": "POST"})
|
|
//
|
|
// Language only matters for literal-delimiter set (Go doesn't use
|
|
// backticks for template literals; TS does; etc.) and for the
|
|
// identifier-name shape, which defaults to the same pattern across
|
|
// the languages we target.
|
|
func extractFirstCallArg(src []byte, line int, wrapperName, lang string) extractedArg {
|
|
if len(src) == 0 || wrapperName == "" || line <= 0 {
|
|
return extractedArg{}
|
|
}
|
|
|
|
lineStart, lineEnd := lineBounds(src, line)
|
|
if lineStart < 0 {
|
|
return extractedArg{}
|
|
}
|
|
|
|
// Search for the wrapper name on the target line only. An edge's
|
|
// Line is the line where the call expression starts; if the call
|
|
// isn't mentioned on that line, we shouldn't be picking up an
|
|
// unrelated call lower in the file.
|
|
nameRE := regexp.MustCompile(`\b` + regexp.QuoteMeta(wrapperName) + `\b\s*(?:<[^>]*>\s*)?\(`)
|
|
lineSlice := src[lineStart:lineEnd]
|
|
loc := nameRE.FindIndex(lineSlice)
|
|
if loc == nil {
|
|
return extractedArg{}
|
|
}
|
|
// Arg list starts just after the opening '('. Use offsets into the
|
|
// full source so balancedCloseParen can scan past line breaks for
|
|
// multi-line argument lists.
|
|
argStart := lineStart + loc[1]
|
|
end := balancedCloseParen(src, argStart-1)
|
|
if end < 0 {
|
|
return extractedArg{}
|
|
}
|
|
argsText := string(src[argStart:end])
|
|
|
|
first, rest := splitFirstArg(argsText)
|
|
return extractedArg{
|
|
Kind: classifyArg(first, lang),
|
|
Value: unquoteIfLiteral(first),
|
|
Method: findMethodProperty(rest),
|
|
}
|
|
}
|
|
|
|
// lineBounds returns the 0-based byte offsets of the start and end of
|
|
// the given 1-based line. Returns -1 for lineStart if the line is out
|
|
// of range.
|
|
func lineBounds(src []byte, line int) (int, int) {
|
|
current := 1
|
|
start := 0
|
|
for i, b := range src {
|
|
if current == line {
|
|
// Find end of this line.
|
|
end := len(src)
|
|
for j := i; j < len(src); j++ {
|
|
if src[j] == '\n' {
|
|
end = j
|
|
break
|
|
}
|
|
}
|
|
return start, end
|
|
}
|
|
if b == '\n' {
|
|
current++
|
|
start = i + 1
|
|
}
|
|
}
|
|
if current == line {
|
|
return start, len(src)
|
|
}
|
|
return -1, -1
|
|
}
|
|
|
|
// balancedCloseParen returns the offset of the ")" that matches the
|
|
// "(" at openIdx, respecting nested parens, string/template literals
|
|
// and comments. Returns -1 if no match is found.
|
|
func balancedCloseParen(src []byte, openIdx int) int {
|
|
if openIdx < 0 || openIdx >= len(src) || src[openIdx] != '(' {
|
|
return -1
|
|
}
|
|
depth := 0
|
|
i := openIdx
|
|
for i < len(src) {
|
|
c := src[i]
|
|
switch c {
|
|
case '(':
|
|
depth++
|
|
i++
|
|
case ')':
|
|
depth--
|
|
if depth == 0 {
|
|
return i
|
|
}
|
|
i++
|
|
case '"', '\'', '`':
|
|
// Skip until the matching close quote. Handles simple
|
|
// escapes; template-literal ${...} interpolation is
|
|
// treated as a string body (we don't recurse into it —
|
|
// good enough for our call-site shapes).
|
|
quote := c
|
|
i++
|
|
for i < len(src) && src[i] != quote {
|
|
if src[i] == '\\' && i+1 < len(src) {
|
|
i += 2
|
|
continue
|
|
}
|
|
i++
|
|
}
|
|
if i < len(src) {
|
|
i++ // consume close quote
|
|
}
|
|
case '/':
|
|
// Skip // line comments and /* block comments.
|
|
if i+1 < len(src) && src[i+1] == '/' {
|
|
for i < len(src) && src[i] != '\n' {
|
|
i++
|
|
}
|
|
} else if i+1 < len(src) && src[i+1] == '*' {
|
|
i += 2
|
|
for i+1 < len(src) && (src[i] != '*' || src[i+1] != '/') {
|
|
i++
|
|
}
|
|
i += 2
|
|
} else {
|
|
i++
|
|
}
|
|
default:
|
|
i++
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
// splitFirstArg returns the first comma-separated argument and the
|
|
// remaining arg list as a single string, respecting nested parens,
|
|
// braces, brackets, and string literals.
|
|
func splitFirstArg(args string) (string, string) {
|
|
depthParen, depthBrace, depthBracket := 0, 0, 0
|
|
i := 0
|
|
for i < len(args) {
|
|
c := args[i]
|
|
switch c {
|
|
case '(':
|
|
depthParen++
|
|
case ')':
|
|
depthParen--
|
|
case '{':
|
|
depthBrace++
|
|
case '}':
|
|
depthBrace--
|
|
case '[':
|
|
depthBracket++
|
|
case ']':
|
|
depthBracket--
|
|
case '"', '\'', '`':
|
|
quote := c
|
|
i++
|
|
for i < len(args) && args[i] != quote {
|
|
if args[i] == '\\' && i+1 < len(args) {
|
|
i += 2
|
|
continue
|
|
}
|
|
i++
|
|
}
|
|
case ',':
|
|
if depthParen == 0 && depthBrace == 0 && depthBracket == 0 {
|
|
return strings.TrimSpace(args[:i]), strings.TrimSpace(args[i+1:])
|
|
}
|
|
}
|
|
i++
|
|
}
|
|
return strings.TrimSpace(args), ""
|
|
}
|
|
|
|
// classifyArg decides whether an argument text is a literal string/
|
|
// template-literal (→ argLiteral), a bare identifier that looks like
|
|
// a function parameter (→ argBareParam), or something else.
|
|
func classifyArg(s, lang string) argKind {
|
|
if s == "" {
|
|
return argUnknown
|
|
}
|
|
// String literal (with or without quotes stripped earlier). The
|
|
// caller strips nothing; we check here.
|
|
if isQuoted(s) {
|
|
// A template literal with interpolation is still a literal
|
|
// for our purposes — NormalizeHTTPPath collapses ${id} to
|
|
// {id}. But we exclude ones that are ENTIRELY a placeholder
|
|
// ("`${url}`") because that's a wrapper forwarding the URL.
|
|
inner := unquote(s)
|
|
if isPureInterpolation(inner) {
|
|
return argBareParam
|
|
}
|
|
return argLiteral
|
|
}
|
|
if isBareIdentifier(s) {
|
|
return argBareParam
|
|
}
|
|
return argUnknown
|
|
}
|
|
|
|
// isQuoted reports whether s begins and ends with ", ', or `.
|
|
func isQuoted(s string) bool {
|
|
if len(s) < 2 {
|
|
return false
|
|
}
|
|
first, last := s[0], s[len(s)-1]
|
|
return (first == '"' || first == '\'' || first == '`') && first == last
|
|
}
|
|
|
|
// unquote strips matching surrounding quotes.
|
|
func unquote(s string) string {
|
|
if !isQuoted(s) {
|
|
return s
|
|
}
|
|
return s[1 : len(s)-1]
|
|
}
|
|
|
|
// unquoteIfLiteral strips quotes when the arg looks like a literal,
|
|
// otherwise returns the arg unchanged. Non-literal returns are unused
|
|
// by the caller (the argKind guard skips them) but keeping the value
|
|
// debuggable is cheap.
|
|
func unquoteIfLiteral(s string) string {
|
|
if isQuoted(s) {
|
|
return unquote(s)
|
|
}
|
|
return s
|
|
}
|
|
|
|
// isPureInterpolation reports whether a template-literal body consists
|
|
// of a single ${ident} with nothing else. Those bodies mean "forward
|
|
// the URL parameter verbatim" — the caller is a wrapper, not a leaf.
|
|
var pureInterpRE = regexp.MustCompile(`^\$\{\s*[a-zA-Z_][a-zA-Z0-9_.]*\s*\}$`)
|
|
|
|
func isPureInterpolation(s string) bool {
|
|
return pureInterpRE.MatchString(strings.TrimSpace(s))
|
|
}
|
|
|
|
// bareIdentRE accepts a single identifier possibly qualified with dots
|
|
// ("path", "opts.path") — Go / TS / JS / Dart all use the same shape.
|
|
var bareIdentRE = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)*$`)
|
|
|
|
func isBareIdentifier(s string) bool {
|
|
return bareIdentRE.MatchString(s)
|
|
}
|
|
|
|
// findMethodProperty scans the remaining args for an object literal
|
|
// whose method: property names the HTTP method. Handles:
|
|
//
|
|
// { method: 'POST', ... }
|
|
// {method:"PATCH"}
|
|
// map[string]any{"method": "DELETE"} // go-ish
|
|
//
|
|
// Returns the method in uppercase, or "" if none found.
|
|
// methodPropRE accepts both the TS/JS shorthand ( method: "POST" ) and
|
|
// the Go/JSON style ( "method": "POST" ) where the key is quoted.
|
|
var methodPropRE = regexp.MustCompile(`(?i)['"` + "`" + `]?\bmethod\b['"` + "`" + `]?\s*:\s*['"` + "`" + `]([A-Za-z]+)['"` + "`" + `]`)
|
|
|
|
func findMethodProperty(rest string) string {
|
|
m := methodPropRE.FindStringSubmatch(rest)
|
|
if len(m) < 2 {
|
|
return ""
|
|
}
|
|
return strings.ToUpper(m[1])
|
|
}
|