Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
7b810ff
feat(php): capture callbacks for 13 more higher-order builtins
zzet Jun 27, 2026
61a479a
feat(expo): extract Property/Constants/Events members, generic clause…
zzet Jun 27, 2026
1ef5b28
feat(rn): capture custom sendEvent(...) wrapper emits in Swift and Ob…
zzet Jun 27, 2026
5e6307d
feat(rn): mine native event emits from Java and Kotlin modules
zzet Jun 27, 2026
6b3ab09
feat(cpp): capture &fn and &Cls::method as function-value references
zzet Jun 27, 2026
87e1fc3
feat(objc): capture @selector(...) as a function-value reference
zzet Jun 27, 2026
0cb3b72
feat(lua): capture function values passed by name and table member
zzet Jun 27, 2026
5e70f12
feat(swift): propagate @objcMembers exposure to class members
zzet Jun 27, 2026
ee987b2
feat(swift): bridge @objc protocol conformance to Objective-C interfaces
zzet Jun 27, 2026
b1b4ea0
feat(ngrx): synthesize effect dispatch edges from ofType action types
zzet Jun 27, 2026
708da64
feat(svelte): resolve $store auto-subscriptions to the imported store
zzet Jun 27, 2026
e6f7ae2
feat(aspnet): prefix-join controller routes and capture verb-less Rou…
zzet Jun 27, 2026
6d04409
feat(cpp): capture chained-call receiver type for factory chains
zzet Jun 27, 2026
fe77a22
feat(php): capture chained-call receiver type for factory chains
zzet Jun 27, 2026
8282383
feat(swift): capture chained member calls and their factory-chain rec…
zzet Jun 27, 2026
7ee6c03
feat(scala): capture chained-call receiver type for factory chains
zzet Jun 27, 2026
2c3ce51
feat(dart): capture chained-call receiver type for factory chains
zzet Jun 27, 2026
62d1b2a
feat(pascal): capture chained-call receiver type for factory chains
zzet Jun 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions internal/contracts/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -659,6 +659,15 @@ func (h *HTTPExtractor) extract(

var out []Contract

// C# ASP.NET attribute routing needs class context: a controller's
// class-level [Route("api/[controller]")] prefix and its verb-less
// [Route("...")] method routes. Scanned once, consumed below.
var csControllers []csController
var csVerbless []csVerblessRoute
if lang == "csharp" {
csControllers, csVerbless = csharpScanControllerRoutes(lines, fileNodes)
}

// File-based routing, Django/DRF/Flask, Rails resources, Express/Fastify
// object routes — every structural framework route pass — run through the
// FrameworkRoutePass registry (framework_registry.go) below. React Router
Expand Down Expand Up @@ -723,6 +732,9 @@ func (h *HTTPExtractor) extract(
subtree = true
}

if pat.role == RoleProvider && lang == "csharp" {
path = csharpJoinControllerRoute(path, csControllers, lineNum, csharpActionName(fileNodes, lineNum))
}
normPath, origNames := NormalizeHTTPPathWithParams(path)
contractID := fmt.Sprintf("http::%s::%s", method, normPath)

Expand Down Expand Up @@ -855,6 +867,8 @@ func (h *HTTPExtractor) extract(
}
}

out = append(out, h.csharpVerblessContracts(filePath, lines, fileNodes, csControllers, csVerbless, lang, tree)...)

// Structural framework route passes — Django/DRF/Flask, Rails resources,
// file-based routes, Express/Fastify object forms — run through the
// FrameworkRoutePass registry. Each pass is language-filtered, has a cheap
Expand Down
200 changes: 200 additions & 0 deletions internal/contracts/http_csharp.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
package contracts

import (
"fmt"
"regexp"
"strings"

"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/parser"
)

// C# ASP.NET attribute-routing prefix-join. A controller's class-level
// `[Route("api/[controller]")]` is the shared prefix of every action route, and
// a method may declare a verb-less `[Route("search")]` (a route with no HTTP-verb
// constraint). The per-line httpPatterns scan sees method routes in isolation, so
// this pass supplies the class context: it joins the controller prefix onto each
// RELATIVE method template (an absolute `/...` or `~/...` template ignores the
// prefix, per ASP.NET) and materialises a contract for each verb-less route.

var (
csharpRouteAttrRe = regexp.MustCompile(`\[Route\(\s*"([^"]+)"\s*\)\]`)
csharpHTTPVerbAttrRe = regexp.MustCompile(`\[Http(?:Get|Post|Put|Delete|Patch|Head|Options)\b`)
csharpClassDeclRe = regexp.MustCompile(`\bclass\s+(\w+)`)
)

// csController is a C# controller class's resolved class-level route prefix and
// line span, used to prefix-join its method routes.
type csController struct {
prefix string
startLine int
endLine int
}

// csVerblessRoute is a method-level `[Route("...")]` with no HTTP-verb attribute
// in the same attribute block -- an ASP.NET route whose verb is unconstrained.
type csVerblessRoute struct {
template string
line int
}

// csharpScanControllerRoutes walks the C# source once, returning each
// controller class's class-level [Route(...)] prefix (with [controller]
// expanded) and each method-level verb-less [Route("...")] site.
func csharpScanControllerRoutes(lines []string, fileNodes []*graph.Node) ([]csController, []csVerblessRoute) {
var controllers []csController
var verbless []csVerblessRoute
pendingRoute := ""
pendingVerb := false
blockLine := -1
reset := func() { pendingRoute, pendingVerb, blockLine = "", false, -1 }
for i, raw := range lines {
line := strings.TrimSpace(raw)
if line == "" {
continue
}
if m := csharpRouteAttrRe.FindStringSubmatch(line); m != nil {
pendingRoute = m[1]
if blockLine < 0 {
blockLine = i
}
continue
}
if csharpHTTPVerbAttrRe.MatchString(line) {
pendingVerb = true
if blockLine < 0 {
blockLine = i
}
continue
}
if strings.HasPrefix(line, "[") {
if blockLine < 0 {
blockLine = i
}
continue
}
// A declaration (or other code) terminates the attribute block.
if cm := csharpClassDeclRe.FindStringSubmatch(line); cm != nil {
if pendingRoute != "" {
prefix := csharpExpandRouteTokens(pendingRoute, cm[1], "")
start, end := csharpClassSpan(cm[1], fileNodes, i+1, len(lines))
controllers = append(controllers, csController{prefix: prefix, startLine: start, endLine: end})
}
} else if pendingRoute != "" && !pendingVerb && strings.Contains(line, "(") {
ln := blockLine
if ln < 0 {
ln = i
}
verbless = append(verbless, csVerblessRoute{template: pendingRoute, line: ln + 1})
}
reset()
}
return controllers, verbless
}

// csharpClassSpan returns the 1-based [start,end] line span of a class -- from
// fileNodes when present, else a fallback of the declaration line to EOF.
func csharpClassSpan(name string, fileNodes []*graph.Node, declLine, total int) (int, int) {
for _, n := range fileNodes {
if n != nil && n.Name == name && n.Kind == graph.KindType {
return n.StartLine, n.EndLine
}
}
return declLine, total
}

// csharpExpandRouteTokens resolves ASP.NET route tokens: [controller] -> the
// controller name minus a trailing "Controller", lower-cased; [action] -> the
// action (method) name, lower-cased. Tokens are matched case-insensitively.
func csharpExpandRouteTokens(tmpl, controller, action string) string {
out := csharpReplaceToken(tmpl, "controller", strings.ToLower(strings.TrimSuffix(controller, "Controller")))
if action != "" {
out = csharpReplaceToken(out, "action", strings.ToLower(action))
}
return out
}

// csharpReplaceToken replaces every case-insensitive `[token]` in s with val.
func csharpReplaceToken(s, token, val string) string {
needle := "[" + token + "]"
for {
lower := strings.ToLower(s)
idx := strings.Index(lower, needle)
if idx < 0 {
return s
}
s = s[:idx] + val + s[idx+len(needle):]
}
}

// csharpJoinControllerRoute prefix-joins a controller's class-level route onto a
// method's RELATIVE template. A template that is absolute (starts with "/" or
// "~/") ignores the controller prefix, per ASP.NET routing.
func csharpJoinControllerRoute(path string, controllers []csController, line int, action string) string {
if strings.HasPrefix(path, "/") || strings.HasPrefix(path, "~/") {
return path
}
prefix := csharpControllerPrefixAt(controllers, line)
if prefix == "" {
return path
}
prefix = csharpReplaceToken(prefix, "action", strings.ToLower(action))
return strings.TrimRight(prefix, "/") + "/" + strings.TrimLeft(path, "/")
}

// csharpControllerPrefixAt returns the class-level route prefix of the innermost
// controller whose span contains line, or "" when none.
func csharpControllerPrefixAt(controllers []csController, line int) string {
best := ""
bestStart := -1
for _, c := range controllers {
if line >= c.startLine && line <= c.endLine && c.startLine > bestStart {
bestStart = c.startLine
best = c.prefix
}
}
return best
}

// csharpActionName returns the bare method name of the symbol enclosing line.
func csharpActionName(fileNodes []*graph.Node, line int) string {
id := findEnclosingSymbol(fileNodes, line)
if i := strings.LastIndex(id, "::"); i >= 0 {
id = id[i+2:]
}
if i := strings.LastIndex(id, "."); i >= 0 {
return id[i+1:]
}
return id
}

// csharpVerblessContracts materialises a route Contract (verb "ANY") for each
// verb-less method-level [Route("...")], prefix-joined to its controller.
func (h *HTTPExtractor) csharpVerblessContracts(filePath string, lines []string, fileNodes []*graph.Node, controllers []csController, verbless []csVerblessRoute, lang string, tree *parser.ParseTree) []Contract {
var out []Contract
for _, vr := range verbless {
path := csharpJoinControllerRoute(vr.template, controllers, vr.line, csharpActionName(fileNodes, vr.line))
normPath, origNames := NormalizeHTTPPathWithParams(path)
method := "ANY"
c := Contract{
ID: fmt.Sprintf("http::%s::%s", method, normPath),
Type: ContractHTTP,
Role: RoleProvider,
SymbolID: findEnclosingSymbol(fileNodes, vr.line),
FilePath: filePath,
Line: vr.line,
Meta: map[string]any{
"method": method,
"path": normPath,
"framework": "aspnet",
},
Confidence: 0.9,
}
if len(origNames) > 0 {
c.Meta["path_param_names"] = origNames
}
EnrichHTTPContractWithTree(&c, lines, fileNodes, lang, tree)
out = append(out, c)
}
return out
}
42 changes: 42 additions & 0 deletions internal/contracts/http_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -959,3 +959,45 @@ func fetchUsers() {
}
}
}

func TestHTTPExtractor_CSharp_RoutePrefixJoin(t *testing.T) {
src := []byte(`using Microsoft.AspNetCore.Mvc;

[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase {
[HttpGet("{id}")]
public IActionResult GetById(int id) { return Ok(); }

[Route("search")]
public IActionResult Search() { return Ok(); }
}
`)
nodes := []*graph.Node{
{ID: "Users.cs::UsersController", Name: "UsersController", Kind: graph.KindType, FilePath: "Users.cs", StartLine: 5, EndLine: 11},
{ID: "Users.cs::UsersController.GetById", Name: "GetById", Kind: graph.KindMethod, FilePath: "Users.cs", StartLine: 6, EndLine: 7},
{ID: "Users.cs::UsersController.Search", Name: "Search", Kind: graph.KindMethod, FilePath: "Users.cs", StartLine: 9, EndLine: 10},
}
cs := (&HTTPExtractor{}).Extract("Users.cs", src, nodes, nil)
byID := map[string]Contract{}
var ids []string
for _, c := range cs {
byID[c.ID] = c
ids = append(ids, c.ID)
}

get, ok := byID["http::GET::/api/users/{p1}"]
if !ok {
t.Fatalf("expected prefix-joined GET route http::GET::/api/users/{p1}, got %v", ids)
}
if get.SymbolID != "Users.cs::UsersController.GetById" {
t.Errorf("GET route should bind GetById, got %q", get.SymbolID)
}
search, ok := byID["http::ANY::/api/users/search"]
if !ok {
t.Fatalf("expected verb-less route http::ANY::/api/users/search, got %v", ids)
}
if search.SymbolID != "Users.cs::UsersController.Search" {
t.Errorf("verb-less route should bind Search, got %q", search.SymbolID)
}
}
28 changes: 28 additions & 0 deletions internal/parser/languages/cpp.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ type cppDeferredCall struct {
name string
line int
isMember bool
receiver string
argTypes []string
}

Expand Down Expand Up @@ -142,6 +143,7 @@ func (e *CppExtractor) Extract(filePath string, src []byte) (*parser.ExtractionR
name: m.Captures["callm.method"].Text,
line: expr.StartLine + 1,
isMember: true,
receiver: cppCallReceiverText(expr.Node, src),
argTypes: extractCppCallArgTypes(expr.Node, src),
})

Expand Down Expand Up @@ -192,10 +194,14 @@ func (e *CppExtractor) Extract(filePath string, src []byte) (*parser.ExtractionR
"scope_arg_types": strings.Join(c.argTypes, ","),
}
}
if c.isMember && c.receiver != "" {
stampFactoryChainReceiver(edge, c.receiver, resolveChainType(c.receiver, nil, result))
}
result.Edges = append(result.Edges, edge)
}

captureCFnPointerDispatch(result, root, filePath, src)
captureFnValueCandidates(result, root, filePath, src)

return result, nil
}
Expand Down Expand Up @@ -652,3 +658,25 @@ func cppArgTypeHint(arg *sitter.Node, src []byte) string {
}
return ""
}

// cppCallReceiverText returns the receiver expression text of a member call
// `recv.method(...)` / `recv->method(...)` -- the object of the call's
// field_expression -- so a factory chain (`make().with().build()`) can be
// typed by resolveChainType.
func cppCallReceiverText(callNode *sitter.Node, src []byte) string {
if callNode == nil {
return ""
}
fn := callNode.ChildByFieldName("function")
if fn == nil || fn.Type() != "field_expression" {
return ""
}
obj := fn.ChildByFieldName("argument")
if obj == nil && fn.NamedChildCount() > 0 {
obj = fn.NamedChild(0)
}
if obj == nil {
return ""
}
return strings.TrimSpace(obj.Content(src))
}
Loading
Loading