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
462 lines
15 KiB
Go
462 lines
15 KiB
Go
package contracts
|
|
|
|
import (
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/zzet/gortex/internal/graph"
|
|
"github.com/zzet/gortex/internal/parser"
|
|
)
|
|
|
|
// Handler-binding for backend route frameworks whose handler is NOT the
|
|
// same-line bare identifier the generic handlerGrp path already resolves. The
|
|
// per-file pass captures the controller class + action and resolves it against
|
|
// the nodes in scope; when the controller lives in another file (Laravel /
|
|
// Rails route files wire handlers defined under app/Http/Controllers or
|
|
// app/controllers) the per-file lookup misses and the bare identifier is
|
|
// stamped so the indexer's module-wide resolveProviderHandlers binds it across
|
|
// files — the cross-file reach a same-file regex extractor cannot match.
|
|
|
|
var (
|
|
// Laravel array action: `[UserController::class, 'index']`.
|
|
laravelArrayHandlerRE = regexp.MustCompile(`\[\s*([A-Za-z_][\w\\]*)::class\s*,\s*['"]([A-Za-z_]\w*)['"]`)
|
|
// Laravel string action: `'UserController@index'` (class may be namespaced).
|
|
laravelStringHandlerRE = regexp.MustCompile(`['"]([A-Za-z_][\w\\]*)@([A-Za-z_]\w*)['"]`)
|
|
// Rails action: `to: 'users#index'` or `=> 'admin/users#create'`.
|
|
railsActionRE = regexp.MustCompile(`(?:to:\s*|=>\s*)['"]([a-z_][\w/]*)#([a-z_]\w*)['"]`)
|
|
)
|
|
|
|
// backendHandlerFrameworks are the provider frameworks whose handler is bound
|
|
// by bindBackendHandler rather than the generic same-line handlerGrp path.
|
|
var backendHandlerFrameworks = map[string]bool{
|
|
"laravel": true,
|
|
"rails": true,
|
|
"spring": true,
|
|
"jaxrs": true,
|
|
"aspnet": true,
|
|
}
|
|
|
|
func isBackendHandlerFramework(framework string) bool {
|
|
return backendHandlerFrameworks[framework]
|
|
}
|
|
|
|
// bindBackendHandler captures and (where possible) resolves the controller
|
|
// handler a backend route dispatches to. It returns the resolved handler node
|
|
// ID when the controller method is among fileNodes (same-file frameworks like
|
|
// Spring, or — in the indexer — once the module-wide pass has the controller
|
|
// in scope), the bare action identifier to stamp for that later cross-file
|
|
// pass, and the declaring controller class so a receiver-correct method is
|
|
// chosen over any same-named action elsewhere.
|
|
func bindBackendHandler(framework, routeLine string, lineIdx int, lines []string, fileNodes []*graph.Node) (symbolID, handlerIdent, handlerClass string) {
|
|
switch framework {
|
|
case "laravel":
|
|
if mm := laravelArrayHandlerRE.FindStringSubmatch(routeLine); mm != nil {
|
|
handlerClass, handlerIdent = lastNamespaceSegment(mm[1]), mm[2]
|
|
} else if mm := laravelStringHandlerRE.FindStringSubmatch(routeLine); mm != nil {
|
|
handlerClass, handlerIdent = lastNamespaceSegment(mm[1]), mm[2]
|
|
}
|
|
case "rails":
|
|
if mm := railsActionRE.FindStringSubmatch(routeLine); mm != nil {
|
|
handlerClass, handlerIdent = railsControllerClass(mm[1]), mm[2]
|
|
}
|
|
case "spring", "jaxrs", "aspnet":
|
|
// The verb annotation sits directly above its handler method; the
|
|
// handler is the next method declaration in the same controller file.
|
|
handlerIdent = forwardScanMethodName(lines, lineIdx)
|
|
}
|
|
|
|
if handlerIdent == "" {
|
|
return "", "", ""
|
|
}
|
|
// Receiver-aware resolution first (precise — picks the action on the named
|
|
// controller), then a bare-name fallback for single-controller files.
|
|
if handlerClass != "" {
|
|
if id := findMethodByNameAndReceiver(fileNodes, handlerIdent, handlerClass); id != "" {
|
|
return id, handlerIdent, handlerClass
|
|
}
|
|
}
|
|
if id := resolveHandlerIdent(fileNodes, handlerIdent); id != "" {
|
|
return id, handlerIdent, handlerClass
|
|
}
|
|
return "", handlerIdent, handlerClass
|
|
}
|
|
|
|
// lastNamespaceSegment strips a PHP namespace prefix so
|
|
// `App\Http\Controllers\UserController` becomes `UserController`.
|
|
func lastNamespaceSegment(class string) string {
|
|
if i := strings.LastIndex(class, `\`); i >= 0 {
|
|
return class[i+1:]
|
|
}
|
|
return class
|
|
}
|
|
|
|
// railsControllerClass maps a Rails route controller token to its class name:
|
|
// `users` -> `UsersController`, `admin/users` -> `UsersController` (the last
|
|
// path segment is the class; the namespace is carried by the module prefix).
|
|
func railsControllerClass(token string) string {
|
|
if i := strings.LastIndex(token, "/"); i >= 0 {
|
|
token = token[i+1:]
|
|
}
|
|
return railsCamelize(token) + "Controller"
|
|
}
|
|
|
|
// railsCamelize converts a snake_case Rails identifier to CamelCase
|
|
// (`user_sessions` -> `UserSessions`).
|
|
func railsCamelize(s string) string {
|
|
var b strings.Builder
|
|
upper := true
|
|
for _, r := range s {
|
|
if r == '_' {
|
|
upper = true
|
|
continue
|
|
}
|
|
if upper {
|
|
b.WriteRune(unicodeToUpper(r))
|
|
upper = false
|
|
continue
|
|
}
|
|
b.WriteRune(r)
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func unicodeToUpper(r rune) rune {
|
|
if r >= 'a' && r <= 'z' {
|
|
return r - ('a' - 'A')
|
|
}
|
|
return r
|
|
}
|
|
|
|
// forwardScanMethodName walks forward from an annotation line to the first
|
|
// method declaration and returns its name — the identifier immediately before
|
|
// the first `(` on that line. Blank lines, further annotations/attributes, and
|
|
// comment lines are skipped so a stacked `@GetMapping` + `@ResponseBody` (Java)
|
|
// or `[HttpGet]` + `[Authorize]` (C#) still reaches the method below them.
|
|
func forwardScanMethodName(lines []string, fromIdx int) string {
|
|
for i := fromIdx + 1; i < len(lines) && i < fromIdx+8; i++ {
|
|
t := strings.TrimSpace(lines[i])
|
|
if t == "" || strings.HasPrefix(t, "@") || strings.HasPrefix(t, "[") ||
|
|
strings.HasPrefix(t, "//") || strings.HasPrefix(t, "*") || strings.HasPrefix(t, "/*") {
|
|
continue
|
|
}
|
|
paren := strings.IndexByte(t, '(')
|
|
if paren <= 0 {
|
|
// A declaration that wraps onto the next line still names the
|
|
// method before any `(`; if there is no `(` yet, keep scanning.
|
|
if name := lastIdentifier(t); name != "" {
|
|
return name
|
|
}
|
|
continue
|
|
}
|
|
return lastIdentifier(t[:paren])
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// lastIdentifier returns the final identifier token in a fragment — the method
|
|
// name in `public List<User> getUsers` or `suspend fun loadUser`.
|
|
func lastIdentifier(s string) string {
|
|
end := len(s)
|
|
for end > 0 && !isIdentByte(s[end-1]) {
|
|
end--
|
|
}
|
|
start := end
|
|
for start > 0 && isIdentByte(s[start-1]) {
|
|
start--
|
|
}
|
|
if start == end {
|
|
return ""
|
|
}
|
|
return s[start:end]
|
|
}
|
|
|
|
// --- Rails resources expander -------------------------------------------
|
|
|
|
var (
|
|
railsResourcesRE = regexp.MustCompile(`(?m)^\s*(resources?)\s+:(\w+)([^\n]*)`)
|
|
railsOnlyRE = regexp.MustCompile(`only:\s*\[([^\]]*)\]`)
|
|
railsExceptRE = regexp.MustCompile(`except:\s*\[([^\]]*)\]`)
|
|
railsSymbolRE = regexp.MustCompile(`:(\w+)`)
|
|
)
|
|
|
|
// railsRESTRoute is one of the seven routes a `resources` declaration expands
|
|
// to: an action, its HTTP method, the path suffix after the collection base,
|
|
// and whether it addresses a member (`/:id`) rather than the collection.
|
|
type railsRESTRoute struct {
|
|
action string
|
|
method string
|
|
suffix string
|
|
member bool
|
|
}
|
|
|
|
// railsCollectionRoutes is the canonical RESTful 7-route expansion Rails
|
|
// generates for `resources :name`.
|
|
var railsCollectionRoutes = []railsRESTRoute{
|
|
{"index", "GET", "", false},
|
|
{"create", "POST", "", false},
|
|
{"new", "GET", "/new", false},
|
|
{"edit", "GET", "/edit", true},
|
|
{"show", "GET", "", true},
|
|
{"update", "PATCH", "", true},
|
|
{"destroy", "DELETE", "", true},
|
|
}
|
|
|
|
// extractRailsResourceRoutes expands `resources :photos` / `resource :photo`
|
|
// (with optional only:/except: filters) into the canonical RESTful routes,
|
|
// binding each action to its controller method (receiver-aware) or stamping
|
|
// the action + controller for the module-wide cross-file pass. This is the
|
|
// implicit-route surface a path-only regex extractor never recovers — one
|
|
// `resources` line becomes seven navigable provider contracts.
|
|
func (h *HTTPExtractor) extractRailsResourceRoutes(filePath, text string, lines []string, fileNodes []*graph.Node, lang string, tree *parser.ParseTree) []Contract {
|
|
var out []Contract
|
|
for _, m := range railsResourcesRE.FindAllStringSubmatchIndex(text, -1) {
|
|
kind := text[m[2]:m[3]]
|
|
name := text[m[4]:m[5]]
|
|
rest := text[m[6]:m[7]]
|
|
lineNum := lineAtOffset(lines, m[0])
|
|
singular := kind == "resource"
|
|
|
|
plural := naivePluralize(name)
|
|
controller := railsCamelize(plural) + "Controller"
|
|
base := "/" + plural
|
|
allowed := railsActionFilter(rest)
|
|
|
|
for _, r := range railsCollectionRoutes {
|
|
if singular && r.action == "index" {
|
|
continue // a singular resource has no index route
|
|
}
|
|
if allowed != nil && !allowed[r.action] {
|
|
continue
|
|
}
|
|
path := base
|
|
if r.member && !singular {
|
|
path += "/:id"
|
|
}
|
|
path += r.suffix
|
|
|
|
normPath, origNames := NormalizeHTTPPathWithParams(path)
|
|
c := Contract{
|
|
ID: fmt.Sprintf("http::%s::%s", r.method, normPath),
|
|
Type: ContractHTTP,
|
|
Role: RoleProvider,
|
|
FilePath: filePath,
|
|
Line: lineNum,
|
|
Confidence: 0.85,
|
|
Meta: map[string]any{
|
|
"method": r.method,
|
|
"path": normPath,
|
|
"framework": "rails",
|
|
"rails_resource": name,
|
|
},
|
|
}
|
|
if len(origNames) > 0 {
|
|
c.Meta["path_param_names"] = origNames
|
|
}
|
|
if id := findMethodByNameAndReceiver(fileNodes, r.action, controller); id != "" {
|
|
c.SymbolID = id
|
|
} else {
|
|
c.Meta["handler_ident"] = r.action
|
|
c.Meta["handler_class"] = controller
|
|
}
|
|
EnrichHTTPContractWithTree(&c, lines, fileNodes, lang, tree)
|
|
out = append(out, c)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
var (
|
|
laravelResourceRE = regexp.MustCompile(`Route::(resource|apiResource)\(\s*['"]([^'"]+)['"]\s*,\s*\\?([\w\\]+)::class\s*\)([^;\n]*)`)
|
|
laravelActionRE = regexp.MustCompile(`['"](\w+)['"]`)
|
|
laravelOnlyRE = regexp.MustCompile(`->only\(\s*\[([^\]]*)\]`)
|
|
laravelExceptRE = regexp.MustCompile(`->except\(\s*\[([^\]]*)\]`)
|
|
)
|
|
|
|
// laravelResourceRoutes is the canonical RESTful 7-route expansion Laravel
|
|
// generates for `Route::resource`. `apiResource` drops create + edit.
|
|
var laravelResourceRoutes = []railsRESTRoute{
|
|
{"index", "GET", "", false},
|
|
{"create", "GET", "/create", false},
|
|
{"store", "POST", "", false},
|
|
{"show", "GET", "", true},
|
|
{"edit", "GET", "/edit", true},
|
|
{"update", "PUT", "", true},
|
|
{"destroy", "DELETE", "", true},
|
|
}
|
|
|
|
// laravelApiResourceSkip are the actions apiResource omits (no HTML form
|
|
// views).
|
|
var laravelApiResourceSkip = map[string]bool{"create": true, "edit": true}
|
|
|
|
// extractLaravelResourceRoutes expands `Route::resource('users',
|
|
// UserController::class)` / `apiResource` into the canonical RESTful routes,
|
|
// binding each action to its controller method (receiver-aware) or stamping
|
|
// the action + controller for the module-wide cross-file pass. `->only`/
|
|
// `->except` filter the action set.
|
|
func (h *HTTPExtractor) extractLaravelResourceRoutes(filePath, text string, lines []string, fileNodes []*graph.Node, lang string, tree *parser.ParseTree) []Contract {
|
|
var out []Contract
|
|
for _, m := range laravelResourceRE.FindAllStringSubmatchIndex(text, -1) {
|
|
kind := text[m[2]:m[3]]
|
|
name := text[m[4]:m[5]]
|
|
controller := laravelSimpleClass(text[m[6]:m[7]])
|
|
rest := ""
|
|
if m[8] >= 0 {
|
|
rest = text[m[8]:m[9]]
|
|
}
|
|
lineNum := lineAtOffset(lines, m[0])
|
|
isAPI := kind == "apiResource"
|
|
allowed := laravelActionFilter(rest)
|
|
base := "/" + name
|
|
|
|
for _, r := range laravelResourceRoutes {
|
|
if isAPI && laravelApiResourceSkip[r.action] {
|
|
continue
|
|
}
|
|
if allowed != nil && !allowed[r.action] {
|
|
continue
|
|
}
|
|
path := base
|
|
if r.member {
|
|
path += "/{id}"
|
|
}
|
|
path += r.suffix
|
|
|
|
normPath, origNames := NormalizeHTTPPathWithParams(path)
|
|
c := Contract{
|
|
ID: fmt.Sprintf("http::%s::%s", r.method, normPath),
|
|
Type: ContractHTTP,
|
|
Role: RoleProvider,
|
|
FilePath: filePath,
|
|
Line: lineNum,
|
|
Confidence: 0.85,
|
|
Meta: map[string]any{
|
|
"method": r.method,
|
|
"path": normPath,
|
|
"framework": "laravel",
|
|
"laravel_resource": name,
|
|
},
|
|
}
|
|
if len(origNames) > 0 {
|
|
c.Meta["path_param_names"] = origNames
|
|
}
|
|
if id := findMethodByNameAndReceiver(fileNodes, r.action, controller); id != "" {
|
|
c.SymbolID = id
|
|
} else {
|
|
c.Meta["handler_ident"] = r.action
|
|
c.Meta["handler_class"] = controller
|
|
}
|
|
EnrichHTTPContractWithTree(&c, lines, fileNodes, lang, tree)
|
|
out = append(out, c)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// laravelActionFilter reads a chained `->only([...])` / `->except([...])`
|
|
// clause (Laravel uses quoted action names) and returns the allowed set, or
|
|
// nil when every action is allowed.
|
|
func laravelActionFilter(rest string) map[string]bool {
|
|
if mm := laravelOnlyRE.FindStringSubmatch(rest); mm != nil {
|
|
allowed := map[string]bool{}
|
|
for _, s := range laravelActionRE.FindAllStringSubmatch(mm[1], -1) {
|
|
allowed[s[1]] = true
|
|
}
|
|
return allowed
|
|
}
|
|
if mm := laravelExceptRE.FindStringSubmatch(rest); mm != nil {
|
|
excluded := map[string]bool{}
|
|
for _, s := range laravelActionRE.FindAllStringSubmatch(mm[1], -1) {
|
|
excluded[s[1]] = true
|
|
}
|
|
allowed := map[string]bool{}
|
|
for _, r := range laravelResourceRoutes {
|
|
if !excluded[r.action] {
|
|
allowed[r.action] = true
|
|
}
|
|
}
|
|
return allowed
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// laravelSimpleClass strips a leading `\` and namespace from a PHP FQCN,
|
|
// returning the bare class name.
|
|
func laravelSimpleClass(fqcn string) string {
|
|
fqcn = strings.TrimPrefix(fqcn, "\\")
|
|
if i := strings.LastIndex(fqcn, "\\"); i >= 0 {
|
|
return fqcn[i+1:]
|
|
}
|
|
return fqcn
|
|
}
|
|
|
|
// railsActionFilter reads an only:/except: clause and returns the set of
|
|
// allowed actions, or nil when every action is allowed.
|
|
func railsActionFilter(rest string) map[string]bool {
|
|
if mm := railsOnlyRE.FindStringSubmatch(rest); mm != nil {
|
|
allowed := map[string]bool{}
|
|
for _, s := range railsSymbolRE.FindAllStringSubmatch(mm[1], -1) {
|
|
allowed[s[1]] = true
|
|
}
|
|
return allowed
|
|
}
|
|
if mm := railsExceptRE.FindStringSubmatch(rest); mm != nil {
|
|
excluded := map[string]bool{}
|
|
for _, s := range railsSymbolRE.FindAllStringSubmatch(mm[1], -1) {
|
|
excluded[s[1]] = true
|
|
}
|
|
allowed := map[string]bool{}
|
|
for _, r := range railsCollectionRoutes {
|
|
if !excluded[r.action] {
|
|
allowed[r.action] = true
|
|
}
|
|
}
|
|
return allowed
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// naivePluralize is a light inflector for Rails resource tokens: it leaves an
|
|
// already-plural token alone, applies the `y`->`ies` rule, and otherwise
|
|
// appends `s`. Enough for the conventional resource names; the exact-noun
|
|
// edge cases (person->people) are not the point of route extraction.
|
|
func naivePluralize(s string) string {
|
|
if strings.HasSuffix(s, "s") {
|
|
return s
|
|
}
|
|
if strings.HasSuffix(s, "y") && len(s) >= 2 && !isVowelByte(s[len(s)-2]) {
|
|
return s[:len(s)-1] + "ies"
|
|
}
|
|
return s + "s"
|
|
}
|
|
|
|
func isVowelByte(b byte) bool {
|
|
switch b {
|
|
case 'a', 'e', 'i', 'o', 'u', 'A', 'E', 'I', 'O', 'U':
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// findMethodByNameAndReceiver resolves a controller action precisely: a method
|
|
// named `name` whose receiver / enclosing type is `receiver`. This is what
|
|
// makes `[UserController::class, 'index']` bind to UserController's index and
|
|
// not some other controller's same-named action — the receiver-correct
|
|
// resolution a name-only regex match cannot achieve.
|
|
func findMethodByNameAndReceiver(fileNodes []*graph.Node, name, receiver string) string {
|
|
for _, n := range fileNodes {
|
|
if n.Kind != graph.KindMethod || n.Name != name {
|
|
continue
|
|
}
|
|
if recv, _ := n.Meta["receiver"].(string); recv == receiver {
|
|
return n.ID
|
|
}
|
|
// Some extractors record the enclosing type on the ID
|
|
// (`file::Controller.action`) rather than a receiver Meta key.
|
|
if strings.HasSuffix(n.ID, "::"+receiver+"."+name) || strings.HasSuffix(n.ID, "."+receiver+"."+name) {
|
|
return n.ID
|
|
}
|
|
}
|
|
return ""
|
|
}
|