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
324 lines
10 KiB
Go
324 lines
10 KiB
Go
package contracts
|
|
|
|
import (
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/zzet/gortex/internal/graph"
|
|
"github.com/zzet/gortex/internal/parser"
|
|
)
|
|
|
|
// Non-decorator Flask routing forms. Gortex's per-line httpPatterns table can
|
|
// only see @app.route / @router.get decorators; the two imperative Flask forms
|
|
// below reference their handler off the call line, so they resolve it against
|
|
// the file's already-extracted symbol nodes:
|
|
//
|
|
// - flask-restful: api.add_resource(ResourceClass, '/path'[, '/path2'])
|
|
// maps the class's get/post/put/delete/... methods to HTTP verbs.
|
|
// - imperative: app.add_url_rule('/path', view_func=fn, methods=[...])
|
|
// the procedural equivalent of @app.route.
|
|
//
|
|
// Both emit Contracts in the exact shape the decorator path produces, so the
|
|
// indexer materialises the same KindContract node + EdgeProvides +
|
|
// EdgeHandlesRoute with no further wiring. Unlike pygraph (which points every
|
|
// add_resource route at the class), gortex points each per-verb route at the
|
|
// specific method node so analyze kind=routes / get_callers land on the real
|
|
// method body.
|
|
|
|
var (
|
|
addResourceCallRE = regexp.MustCompile(`\.add_resource\s*\(`)
|
|
addURLRuleCallRE = regexp.MustCompile(`\.add_url_rule\s*\(`)
|
|
viewFuncKwargRE = regexp.MustCompile(`view_func\s*=\s*([A-Za-z_][\w.]*)`)
|
|
methodsKwargRE = regexp.MustCompile(`methods\s*=\s*(\[[^\]]*\]|\([^)]*\)|["'][^"']*["'])`)
|
|
quotedTokenRE = regexp.MustCompile(`["']([^"']+)["']`)
|
|
pyIdentRE = regexp.MustCompile(`^[A-Za-z_][\w.]*$`)
|
|
)
|
|
|
|
// flaskVerbMethods is the closed set of resource methods flask-restful maps to
|
|
// HTTP verbs, in canonical order.
|
|
var flaskVerbMethods = []string{"get", "post", "put", "delete", "patch", "head", "options"}
|
|
|
|
// extractFlaskRestfulRoutes handles api.add_resource(ResourceClass, '/p'...).
|
|
func (h *HTTPExtractor) extractFlaskRestfulRoutes(filePath, text string, lines []string, fileNodes []*graph.Node, lang string, tree *parser.ParseTree) []Contract {
|
|
var out []Contract
|
|
for _, m := range addResourceCallRE.FindAllStringIndex(text, -1) {
|
|
args := callTrailSlice(text, m[0])
|
|
if args == "" {
|
|
continue
|
|
}
|
|
parts := splitPyParams(args)
|
|
if len(parts) < 2 {
|
|
continue
|
|
}
|
|
className := pyBareIdentTail(strings.TrimSpace(parts[0]))
|
|
if className == "" {
|
|
continue
|
|
}
|
|
var paths []string
|
|
for _, p := range parts[1:] {
|
|
if lit, ok := pyStringLiteral(p); ok && lit != "" {
|
|
paths = append(paths, lit)
|
|
}
|
|
}
|
|
if len(paths) == 0 {
|
|
continue
|
|
}
|
|
lineNum := lineAtOffset(lines, m[0])
|
|
classID := filePath + "::" + className
|
|
verbMethods := resourceVerbMethods(fileNodes, classID)
|
|
classNode := findTypeNodeByName(fileNodes, className)
|
|
|
|
for _, path := range paths {
|
|
if len(verbMethods) > 0 {
|
|
for _, verb := range flaskVerbMethods {
|
|
mid, ok := verbMethods[verb]
|
|
if !ok {
|
|
continue
|
|
}
|
|
out = append(out, h.buildFlaskContract(filePath, strings.ToUpper(verb), path, mid, lineNum, lines, fileNodes, lang, tree))
|
|
}
|
|
continue
|
|
}
|
|
// GET fallback (parity with pygraph): the class is unresolvable or
|
|
// defines no HTTP-verb methods, but a route node should still
|
|
// appear for discoverability. Attach to the class node when known,
|
|
// else the enclosing symbol.
|
|
sym := findEnclosingSymbol(fileNodes, lineNum)
|
|
if classNode != nil {
|
|
sym = classNode.ID
|
|
}
|
|
out = append(out, h.buildFlaskContract(filePath, "GET", path, sym, lineNum, lines, fileNodes, lang, tree))
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// flaskRouteDecoratorRE matches a Flask @bp.route('/path', methods=[...])
|
|
// decorator: group 1 the receiver variable, group 2 the path, group 3 the
|
|
// remainder of the call (scanned for methods=).
|
|
var flaskRouteDecoratorRE = regexp.MustCompile(`@(\w+)\.route\(\s*["']([^"']+)["'](.*)`)
|
|
|
|
// flaskDefRE matches the Python function definition the decorator wraps.
|
|
var flaskDefRE = regexp.MustCompile(`^\s*(?:async\s+)?def\s+([A-Za-z_]\w*)`)
|
|
|
|
// extractFlaskDecoratorRoutes handles @bp.route('/path', methods=[...]),
|
|
// expanding one contract per HTTP method (Flask defaults to GET) and resolving
|
|
// the wrapped view function. It supersedes the per-line provider table for
|
|
// @route so the methods= list is honoured rather than collapsed to ANY.
|
|
func (h *HTTPExtractor) extractFlaskDecoratorRoutes(filePath, text string, lines []string, fileNodes []*graph.Node, lang string, tree *parser.ParseTree) []Contract {
|
|
var out []Contract
|
|
for i, line := range lines {
|
|
m := flaskRouteDecoratorRE.FindStringSubmatch(line)
|
|
if m == nil {
|
|
continue
|
|
}
|
|
receiver, path, rest := m[1], m[2], m[3]
|
|
lineNum := i + 1
|
|
|
|
methods := flaskMethodsKwarg(rest)
|
|
if len(methods) == 0 {
|
|
methods = []string{"GET"}
|
|
}
|
|
|
|
// The view is the next `def` below the decorator stack.
|
|
handlerID := ""
|
|
for j := i + 1; j < len(lines); j++ {
|
|
trimmed := strings.TrimSpace(lines[j])
|
|
if trimmed == "" || strings.HasPrefix(trimmed, "@") {
|
|
continue
|
|
}
|
|
if dm := flaskDefRE.FindStringSubmatch(lines[j]); dm != nil {
|
|
handlerID = findFunctionByName(fileNodes, dm[1])
|
|
}
|
|
break
|
|
}
|
|
|
|
for _, method := range methods {
|
|
out = append(out, h.buildFlaskDecoratorContract(filePath, method, path, handlerID, receiver, lineNum, lines, fileNodes, lang, tree))
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// buildFlaskDecoratorContract assembles one provider contract for a @route
|
|
// method. The route receiver is recorded so the Blueprint url_prefix join can
|
|
// recover it without re-parsing.
|
|
func (h *HTTPExtractor) buildFlaskDecoratorContract(filePath, method, path, symbolID, receiver string, lineNum int, lines []string, fileNodes []*graph.Node, lang string, tree *parser.ParseTree) Contract {
|
|
normPath, origNames := NormalizeHTTPPathWithParams(path)
|
|
meta := map[string]any{
|
|
"method": method,
|
|
"path": normPath,
|
|
"framework": "flask",
|
|
"route_receiver": receiver,
|
|
}
|
|
if len(origNames) > 0 {
|
|
meta["path_param_names"] = origNames
|
|
}
|
|
c := Contract{
|
|
ID: fmt.Sprintf("http::%s::%s", method, normPath),
|
|
Type: ContractHTTP,
|
|
Role: RoleProvider,
|
|
SymbolID: symbolID,
|
|
FilePath: filePath,
|
|
Line: lineNum,
|
|
Meta: meta,
|
|
Confidence: 0.9,
|
|
}
|
|
EnrichHTTPContractWithTree(&c, lines, fileNodes, lang, tree)
|
|
return c
|
|
}
|
|
|
|
// extractFlaskAddURLRule handles app.add_url_rule('/p', view_func=fn, methods=[...]).
|
|
func (h *HTTPExtractor) extractFlaskAddURLRule(filePath, text string, lines []string, fileNodes []*graph.Node, lang string, tree *parser.ParseTree) []Contract {
|
|
var out []Contract
|
|
for _, m := range addURLRuleCallRE.FindAllStringIndex(text, -1) {
|
|
args := callTrailSlice(text, m[0])
|
|
if args == "" {
|
|
continue
|
|
}
|
|
parts := splitPyParams(args)
|
|
if len(parts) == 0 {
|
|
continue
|
|
}
|
|
path, ok := pyStringLiteral(parts[0])
|
|
if !ok || path == "" {
|
|
continue
|
|
}
|
|
// view_func from kwarg, else positional arg[1] if it is a bare ident.
|
|
viewFunc := ""
|
|
if mm := viewFuncKwargRE.FindStringSubmatch(args); len(mm) == 2 {
|
|
viewFunc = mm[1]
|
|
} else if len(parts) >= 2 {
|
|
cand := strings.TrimSpace(parts[1])
|
|
if pyIdentRE.MatchString(cand) && !strings.Contains(cand, "=") {
|
|
viewFunc = cand
|
|
}
|
|
}
|
|
if viewFunc == "" {
|
|
// Parity with pygraph: only emit when a view_func resolved.
|
|
continue
|
|
}
|
|
viewFunc = pyBareIdentTail(viewFunc)
|
|
|
|
methods := flaskMethodsKwarg(args)
|
|
if len(methods) == 0 {
|
|
methods = []string{"GET"}
|
|
}
|
|
lineNum := lineAtOffset(lines, m[0])
|
|
symbolID := findFunctionByName(fileNodes, viewFunc)
|
|
if symbolID == "" {
|
|
symbolID = findEnclosingSymbol(fileNodes, lineNum)
|
|
}
|
|
for _, method := range methods {
|
|
out = append(out, h.buildFlaskContract(filePath, method, path, symbolID, lineNum, lines, fileNodes, lang, tree))
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// buildFlaskContract builds a provider HTTP contract identical in shape to the
|
|
// decorator path so the indexer materialises the same graph elements.
|
|
func (h *HTTPExtractor) buildFlaskContract(filePath, method, path, symbolID string, lineNum int, lines []string, fileNodes []*graph.Node, lang string, tree *parser.ParseTree) Contract {
|
|
normPath, origNames := NormalizeHTTPPathWithParams(path)
|
|
contractID := fmt.Sprintf("http::%s::%s", method, normPath)
|
|
meta := map[string]any{
|
|
"method": method,
|
|
"path": normPath,
|
|
"framework": "flask",
|
|
}
|
|
if len(origNames) > 0 {
|
|
meta["path_param_names"] = origNames
|
|
}
|
|
c := Contract{
|
|
ID: contractID,
|
|
Type: ContractHTTP,
|
|
Role: RoleProvider,
|
|
SymbolID: symbolID,
|
|
FilePath: filePath,
|
|
Line: lineNum,
|
|
Meta: meta,
|
|
Confidence: 0.9,
|
|
}
|
|
EnrichHTTPContractWithTree(&c, lines, fileNodes, lang, tree)
|
|
return c
|
|
}
|
|
|
|
// resourceVerbMethods returns verb→methodNodeID for the HTTP-verb methods a
|
|
// resource class defines, keyed on the deterministic method node ID
|
|
// (filePath::Class.verb).
|
|
func resourceVerbMethods(fileNodes []*graph.Node, classID string) map[string]string {
|
|
out := map[string]string{}
|
|
for _, v := range flaskVerbMethods {
|
|
want := classID + "." + v
|
|
for _, n := range fileNodes {
|
|
if n.Kind == graph.KindMethod && n.ID == want {
|
|
out[v] = n.ID
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// findTypeNodeByName resolves a class/type node by short name (findFunctionByName
|
|
// deliberately excludes KindType, so add_resource needs its own lookup).
|
|
func findTypeNodeByName(fileNodes []*graph.Node, name string) *graph.Node {
|
|
for _, n := range fileNodes {
|
|
if n.Kind == graph.KindType && n.Name == name {
|
|
return n
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// flaskMethodsKwarg parses methods=['POST','PUT'] / methods=('GET',) /
|
|
// methods="GET" into upper-cased verb names.
|
|
func flaskMethodsKwarg(argsText string) []string {
|
|
mm := methodsKwargRE.FindStringSubmatch(argsText)
|
|
if len(mm) < 2 {
|
|
return nil
|
|
}
|
|
var out []string
|
|
for _, q := range quotedTokenRE.FindAllStringSubmatch(mm[1], -1) {
|
|
out = append(out, strings.ToUpper(q[1]))
|
|
}
|
|
return out
|
|
}
|
|
|
|
// pyStringLiteral strips matching quotes (and an optional r/f/b prefix) from a
|
|
// Python string-literal token, reporting whether the token was a literal.
|
|
func pyStringLiteral(tok string) (string, bool) {
|
|
tok = strings.TrimSpace(tok)
|
|
for len(tok) > 1 {
|
|
switch tok[0] {
|
|
case 'r', 'R', 'f', 'F', 'b', 'B', 'u', 'U':
|
|
if tok[1] == '\'' || tok[1] == '"' {
|
|
tok = tok[1:]
|
|
continue
|
|
}
|
|
}
|
|
break
|
|
}
|
|
if len(tok) >= 2 {
|
|
q := tok[0]
|
|
if (q == '\'' || q == '"') && tok[len(tok)-1] == q {
|
|
return tok[1 : len(tok)-1], true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// pyBareIdentTail strips a module qualifier (pkg.UserResource → UserResource)
|
|
// and returns "" if the result is not a bare identifier.
|
|
func pyBareIdentTail(tok string) string {
|
|
tok = strings.TrimSpace(tok)
|
|
if i := strings.LastIndex(tok, "."); i >= 0 {
|
|
tok = tok[i+1:]
|
|
}
|
|
if tok == "" || !pyIdentRE.MatchString(tok) {
|
|
return ""
|
|
}
|
|
return tok
|
|
}
|