Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

1205 lines
43 KiB
Go

package contracts
import (
"fmt"
"regexp"
"sort"
"strconv"
"strings"
"github.com/zzet/gortex/internal/parser"
"github.com/zzet/gortex/internal/graph"
)
// HTTPExtractor detects HTTP route provider and consumer patterns across
// multiple languages using regex matching on source text.
type HTTPExtractor struct {
// ClientAliases are project-defined wrapped HTTP-client function
// names (e.g. "apiGet", "apiPost", "client.request"). Calls to any
// of these are treated as HTTP consumer contracts even though they
// don't match the built-in fetch/axios heuristics. Empty disables
// the alias pass. Sourced from index.http_client_aliases. See
// detectClientAliasConsumers for the supported call shapes.
ClientAliases []string
}
var (
_ Extractor = (*HTTPExtractor)(nil)
_ TreeAwareExtractor = (*HTTPExtractor)(nil)
_ StoreAwareExtractor = (*HTTPExtractor)(nil)
)
// SupportedLanguages returns the languages this extractor can analyse.
func (h *HTTPExtractor) SupportedLanguages() []string {
return []string{
"go", "typescript", "javascript", "python",
"java", "kotlin", "dart", "swift",
"rust", "csharp", "ruby", "php", "elixir", "scala",
// File-based routing: page files in these frameworks carry the route
// in their path, so the extractor must see them even though they are
// not otherwise HTTP-bearing languages.
"astro", "svelte", "vue",
// YAML: Drupal *.routing.yml route declarations (a FrameworkRoutePass
// filters to the routing files by name).
"yaml",
}
}
// httpPattern describes a single regex pattern that matches an HTTP route
// declaration or call.
type httpPattern struct {
re *regexp.Regexp
role Role
method string // HTTP method (empty = extract from match)
methodGrp int // capture group index for method when not fixed
pathGrp int // capture group index for path
// handlerGrp is the capture group for the handler identifier on the
// provider side (e.g. `listUsers` in `r.GET("/users", listUsers)`).
// 0 = not captured. When set and the capture resolves to a function
// node in the same file, the Contract's SymbolID is the handler, not
// the enclosing registration function — so "trace a request" queries
// land on the business logic instead of setupRoutes().
handlerGrp int
framework string
confidence float64
languages []string // empty = all
}
// Compiled patterns -----------------------------------------------------------
var httpPatterns = []httpPattern{
// ---- Go providers (high confidence, framework-specific) ----
// Go 1.22+ stdlib mux: mux.HandleFunc("METHOD /path", h). The
// method is embedded in the pattern as a prefix and must be
// split out so the resulting contract ID matches the consumer
// side's http::METHOD::path shape.
{
re: regexp.MustCompile(`(?:Handle|HandleFunc)\(\s*["` + "`" + `](GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+(/[^"` + "`" + `]*)["` + "`" + `]\s*(?:,\s*(\w+))?`),
role: RoleProvider,
methodGrp: 1,
pathGrp: 2,
handlerGrp: 3,
framework: "net/http",
confidence: 0.95,
languages: []string{"go"},
},
// Legacy net/http HandleFunc with pattern-only path. Requires the
// captured path to start with "/" (no leading verb), so the Go
// 1.22+ "METHOD /path" form above doesn't double-match and emit
// a bogus http::ANY::/VERB path contract alongside the canonical
// http::VERB::/path one.
{
re: regexp.MustCompile(`(?:Handle|HandleFunc)\(\s*["` + "`" + `](/[^"` + "`" + `]*)["` + "`" + `]\s*(?:,\s*(\w+))?`),
role: RoleProvider,
method: "ANY",
pathGrp: 1,
handlerGrp: 2,
framework: "net/http",
confidence: 0.9,
languages: []string{"go"},
},
{
// Match router/group method calls but not http.Get/http.Post (stdlib consumers).
re: regexp.MustCompile(`(?:^|[^/])\b(?:r|g|e|router|group|api|v1|mux|app)\.(Get|Post|Put|Delete|Patch|Head|Options)\(\s*["` + "`" + `]([^"` + "`" + `]+)["` + "`" + `]\s*(?:,\s*(\w+))?`),
role: RoleProvider,
methodGrp: 1,
pathGrp: 2,
handlerGrp: 3,
framework: "gin/echo/chi",
confidence: 0.9,
languages: []string{"go"},
},
{
// Path must start with "/". Otherwise the bare `.VERB(` anchor
// matches verb names that appear inside string literals (e.g.
// the prefilter marker `[]byte(".GET(")` in this very file),
// and the path capture overshoots until the next quote — emitting
// contracts whose "path" is a chunk of source code.
re: regexp.MustCompile(`\.(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\(\s*["` + "`" + `](/[^"` + "`" + `]*)["` + "`" + `]\s*(?:,\s*(\w+))?`),
role: RoleProvider,
methodGrp: 1,
pathGrp: 2,
handlerGrp: 3,
framework: "fiber",
confidence: 0.9,
languages: []string{"go"},
},
// ---- TS/JS providers ----
{
re: regexp.MustCompile(`(?:app|router)\.(get|post|put|delete|patch|head|options|all)\(\s*["'` + "`" + `]([^"'` + "`" + `]+)["'` + "`" + `]\s*(?:,\s*(\w+))?`),
role: RoleProvider,
methodGrp: 1,
pathGrp: 2,
handlerGrp: 3,
framework: "express",
confidence: 0.9,
languages: []string{"typescript", "javascript"},
},
{
// Fastify instance verbs (the instance is conventionally `fastify`,
// which the express receiver set does not cover).
re: regexp.MustCompile(`fastify\.(get|post|put|delete|patch|head|options|all)\(\s*["'` + "`" + `]([^"'` + "`" + `]+)["'` + "`" + `]\s*(?:,\s*(\w+))?`),
role: RoleProvider,
methodGrp: 1,
pathGrp: 2,
handlerGrp: 3,
framework: "fastify",
confidence: 0.9,
languages: []string{"typescript", "javascript"},
},
{
// Koa-router's `.del` alias for DELETE (express uses `.delete`).
re: regexp.MustCompile(`(?:router|app)\.del\(\s*["'` + "`" + `]([^"'` + "`" + `]+)["'` + "`" + `]\s*(?:,\s*(\w+))?`),
role: RoleProvider,
method: "DELETE",
pathGrp: 1,
handlerGrp: 2,
framework: "koa",
confidence: 0.85,
languages: []string{"typescript", "javascript"},
},
{
re: regexp.MustCompile(`@(Get|Post|Put|Delete|Patch|Head|Options|All)\(\s*["'` + "`" + `]([^"'` + "`" + `]+)["'` + "`" + `]`),
role: RoleProvider,
methodGrp: 1,
pathGrp: 2,
framework: "nestjs",
confidence: 0.9,
languages: []string{"typescript", "javascript"},
},
// ---- Python providers ----
{
re: regexp.MustCompile(`@\w+\.(get|post|put|delete|patch|head|options)\(\s*["']([^"']+)["']`),
role: RoleProvider,
methodGrp: 1,
pathGrp: 2,
framework: "fastapi/flask",
confidence: 0.9,
languages: []string{"python"},
},
// Flask @route is handled by the node-aware extractFlaskDecoratorRoutes
// pass (it expands methods=[...] and resolves the view), not the
// per-line table.
// Django path/re_path/url routing is handled by the node-aware
// extractDjangoRoutes pass (it resolves the view handler), not the
// per-line table.
// ---- Java providers ----
{
re: regexp.MustCompile(`@(Get|Post|Put|Delete|Patch)Mapping\(\s*(?:value\s*=\s*)?["']([^"']+)["']`),
role: RoleProvider,
methodGrp: 1,
pathGrp: 2,
framework: "spring",
confidence: 0.9,
languages: []string{"java", "kotlin"},
},
{
re: regexp.MustCompile(`@RequestMapping\(\s*(?:value\s*=\s*)?["']([^"']+)["']`),
role: RoleProvider,
method: "ANY",
pathGrp: 1,
framework: "spring",
confidence: 0.9,
languages: []string{"java", "kotlin"},
},
{
re: regexp.MustCompile(`@(GET|POST|PUT|DELETE|PATCH)\s+@Path\(\s*["']([^"']+)["']`),
role: RoleProvider,
methodGrp: 1,
pathGrp: 2,
framework: "jaxrs",
confidence: 0.9,
languages: []string{"java", "kotlin"},
},
// ---- Go consumers ----
{
re: regexp.MustCompile(`http\.(Get|Post|Head)\(\s*["` + "`" + `]([^"` + "`" + `]+)["` + "`" + `]`),
role: RoleConsumer,
methodGrp: 1,
pathGrp: 2,
framework: "net/http",
confidence: 0.9,
languages: []string{"go"},
},
{
re: regexp.MustCompile(`http\.NewRequest\(\s*["` + "`" + `](\w+)["` + "`" + `]\s*,\s*["` + "`" + `]([^"` + "`" + `]+)["` + "`" + `]`),
role: RoleConsumer,
methodGrp: 1,
pathGrp: 2,
framework: "net/http",
confidence: 0.9,
languages: []string{"go"},
},
// ---- TS/JS consumers ----
// fetch with explicit `method: '<VERB>'` in the options object —
// tried first so the generic GET pattern below doesn't steal the
// match.
{
re: regexp.MustCompile(`fetch\(\s*["'` + "`" + `]([^"'` + "`" + `]+)["'` + "`" + `][^)]*?method\s*:\s*["'](\w+)["']`),
role: RoleConsumer,
methodGrp: 2,
pathGrp: 1,
framework: "fetch",
confidence: 0.9,
languages: []string{"typescript", "javascript"},
},
{
re: regexp.MustCompile(`fetch\(\s*["'` + "`" + `]([^"'` + "`" + `]+)["'` + "`" + `]`),
role: RoleConsumer,
method: "GET",
pathGrp: 1,
framework: "fetch",
confidence: 0.7,
languages: []string{"typescript", "javascript"},
},
// Axios. The optional `<...>` between the method name and the
// opening paren is TypeScript's generic form — axios.post<Resp, Req>(...)
// — which the enrichment layer uses to pin response / request
// types. `[^<>(),]` inside the generic keeps the matcher fast and
// stops greedy consumption from crossing the path argument.
{
re: regexp.MustCompile(`axios\.(get|post|put|delete|patch|head|options)(?:<[^<>()]*>)?\(\s*["'` + "`" + `]([^"'` + "`" + `]+)["'` + "`" + `]`),
role: RoleConsumer,
methodGrp: 1,
pathGrp: 2,
framework: "axios",
confidence: 0.9,
languages: []string{"typescript", "javascript"},
},
// ---- Python / Java consumers ----
// Python (requests/httpx/aiohttp/urllib3) and JVM (OkHttp/RestTemplate/
// WebClient) HTTP-client consumers are detected by the import-gated
// detectClientLibConsumers pass (http_client_libs.go), which binds the call
// receiver to a resolved library import instead of matching a bare call-text
// substring — so a local variable named `requests` or a non-HTTP `obj.get`
// accessor no longer mints a spurious consumer contract.
// ---- Dart consumers ----
// Dio (the dominant HTTP client in modern Flutter apps). Matches
// identifiers like `dio`, `_dio`, `apiDio` etc. invoking a method
// with a string-literal path.
{
re: regexp.MustCompile(`\b_?\w*[Dd]io\.(get|post|put|delete|patch|head)\(\s*['"]([^'"]+)['"]`),
role: RoleConsumer,
methodGrp: 1,
pathGrp: 2,
framework: "dio",
confidence: 0.9,
languages: []string{"dart"},
},
// package:http functional API — http.get(Uri.parse('/x')) or
// http.post('/x'). The regex captures either the string inside
// Uri.parse or the direct literal argument.
{
re: regexp.MustCompile(`\bhttp\.(get|post|put|delete|patch|head)\(\s*(?:Uri\.parse\(\s*)?['"]([^'"]+)['"]`),
role: RoleConsumer,
methodGrp: 1,
pathGrp: 2,
framework: "package:http",
confidence: 0.8,
languages: []string{"dart"},
},
// ---- Rust providers ----
// Axum: `Router::new().route("/users", get(handler))` and
// `Router::new().route("/users/:id", post(create).delete(remove))`.
// The method comes from the `get|post|...` function call; the
// path is the first string literal in `.route(`.
// Axum `.route("/path", <chain>)` provider routes — including the
// chained-method form `get(h1).post(h2)` — are handled by the Rust route
// pass (http_rust.go), which emits one Contract per method in the chain.
// Actix-web macro form: `#[get("/path")]` / `#[post("/path")]`.
{
re: regexp.MustCompile(`#\[(get|post|put|delete|patch|head|options)\(\s*"([^"]+)"`),
role: RoleProvider,
methodGrp: 1,
pathGrp: 2,
framework: "actix",
confidence: 0.95,
languages: []string{"rust"},
},
// Rocket macro form: `#[get("/path")]` (same syntax as Actix —
// the detection code can't tell them apart from the route line
// alone; tag as "rust" and let repo context disambiguate).
// Covered by the Actix regex above.
// ---- Rust consumers ----
// reqwest consumers (`client.get("/users")`) are detected by the
// import-gated detectClientLibConsumers pass (http_client_libs.go): the call
// receiver must bind to a `use reqwest::…` import, so an unregistered crate
// like surf / hyper never mints a consumer contract.
// ---- C# ASP.NET providers ----
// Attribute routing: `[HttpGet("/path")]`, `[HttpPost]` +
// `[Route("path")]`. First form is the clean one.
{
re: regexp.MustCompile(`\[Http(Get|Post|Put|Delete|Patch|Head|Options)\(\s*"([^"]+)"`),
role: RoleProvider,
methodGrp: 1,
pathGrp: 2,
framework: "aspnet",
confidence: 0.95,
languages: []string{"csharp"},
},
// Minimal APIs: `app.MapGet("/path", handler)`.
{
re: regexp.MustCompile(`\b(?:app|routes?)\.Map(Get|Post|Put|Delete|Patch|Head|Options)\(\s*"([^"]+)"`),
role: RoleProvider,
methodGrp: 1,
pathGrp: 2,
framework: "aspnet",
confidence: 0.9,
languages: []string{"csharp"},
},
// ---- C# consumers ----
// HttpClient: `client.GetAsync("/path")`, `PostAsync`, etc.
{
re: regexp.MustCompile(`\b\w+\.(Get|Post|Put|Delete|Patch|Head|Options)(?:Async|String)?\(\s*"([^"]+)"`),
role: RoleConsumer,
methodGrp: 1,
pathGrp: 2,
framework: "httpclient",
confidence: 0.7,
languages: []string{"csharp"},
},
// ---- Ruby on Rails providers ----
// Explicit route: `get '/users', to: 'users#index'`,
// `post '/users' => 'users#create'`.
{
re: regexp.MustCompile(`(?m)^\s*(get|post|put|patch|delete|head|options)\s+['"]([^'"]+)['"]\s*(?:,|=>)`),
role: RoleProvider,
methodGrp: 1,
pathGrp: 2,
framework: "rails",
confidence: 0.9,
languages: []string{"ruby"},
},
// ---- Ruby consumers ----
// Faraday / HTTParty / Net::HTTP / RestClient consumers are detected by the
// import-gated detectClientLibConsumers pass (http_client_libs.go), which
// requires a `require 'faraday'` (etc.) and binds the receiver to it — so an
// arbitrary `obj.get('...')` accessor is no longer a consumer.
// ---- PHP Laravel providers ----
// `Route::get('/path', ...)`, `Route::post('/path', [Controller::class, 'method'])`.
{
re: regexp.MustCompile(`Route::(get|post|put|patch|delete|head|options)\(\s*['"]([^'"]+)['"]`),
role: RoleProvider,
methodGrp: 1,
pathGrp: 2,
framework: "laravel",
confidence: 0.95,
languages: []string{"php"},
},
// Symfony attribute routing: `#[Route("/path", methods: ["POST"])]`.
{
re: regexp.MustCompile(`#\[Route\(\s*['"]([^'"]+)['"][^)]*methods:\s*\[\s*['"](\w+)['"]`),
role: RoleProvider,
methodGrp: 2,
pathGrp: 1,
framework: "symfony",
confidence: 0.9,
languages: []string{"php"},
},
// ---- PHP consumers ----
// Guzzle consumers (`$client->get('/path')`, `$client->request('POST', …)`)
// are detected by the import-gated detectClientLibConsumers pass
// (http_client_libs.go): the variable must be constructed from a
// `use GuzzleHttp\…` import before its verb calls become consumers.
// ---- Elixir Phoenix providers ----
// `get "/users", UserController, :index` inside router.ex scope.
{
re: regexp.MustCompile(`(?m)^\s*(get|post|put|patch|delete|head|options)\s+"([^"]+)"\s*,`),
role: RoleProvider,
methodGrp: 1,
pathGrp: 2,
framework: "phoenix",
confidence: 0.9,
languages: []string{"elixir"},
},
// ---- Dart providers (shelf_router) ----
// `router.get('/path', handler)` and Dart's cascade form
// `..get('/path', handler)` — the latter dominates in
// idiomatic shelf_router code.
{
re: regexp.MustCompile(`(?:\.\.|\b\w*[Rr]outer\.)(get|post|put|delete|patch|head|options)\(\s*['"]([^'"]+)['"]\s*,\s*(\w+)?`),
role: RoleProvider,
methodGrp: 1,
pathGrp: 2,
handlerGrp: 3,
framework: "shelf_router",
confidence: 0.85,
languages: []string{"dart"},
},
// ---- Swift providers (Vapor / Hummingbird) ----
// `app.get("users") { req in ... }` (trailing-closure handler) and
// `app.get("users", use: listUsers)` (use: labelled handler). The
// receiver is anchored to the conventional names (app / routes /
// router) — same trade-off express makes for route-group vars.
{
// Receiver is captured (group 1) — not just app/routes/router — so a
// route declared on a `.grouped(...)` group var carries its receiver
// for the prefix-join pass. The `use:` label binds the handler.
re: regexp.MustCompile(`\b(\w+)\.(get|post|put|delete|patch)\(\s*"([^"]+)"(?:[^{)]*?\buse:\s*(\w+))?`),
role: RoleProvider,
methodGrp: 2,
pathGrp: 3,
handlerGrp: 4,
framework: "vapor",
confidence: 0.85,
languages: []string{"swift"},
},
// ---- Swift consumers (Alamofire) ----
// `AF.request("https://…", method: .get)` /
// `session.request("…", method: .post)`.
{
re: regexp.MustCompile(`\b(?:AF|\w*[Ss]ession)\.request\(\s*"([^"]+)"[^{)]*?\bmethod:\s*\.(get|post|put|delete|patch)`),
role: RoleConsumer,
methodGrp: 2,
pathGrp: 1,
framework: "alamofire",
confidence: 0.8,
languages: []string{"swift"},
},
}
// httpPrefilterMarkers is the per-language substring prefilter.
// Files whose language appears here must contain at least one
// marker before the ~30 HTTP regexes run — otherwise we skip the
// file entirely. See the gRPC reference implementation for the
// pattern's origin. Languages whose HTTP patterns hinge on
// bare keywords (python's `path(`, ruby/elixir's top-level `get`,
// `post`) are intentionally absent: any marker tight enough to
// reject non-HTTP files would also reject legitimate HTTP files,
// so the regex scan carries the whole cost.
var httpPrefilterMarkers = map[string][][]byte{
// Phase 2 of spec-contract-extraction.md removed the "go" entry.
// The AST detector for Go routes (detectGoRoutesAST) doesn't
// self-match its own marker source, so the workaround that
// motivated those markers — and the Fiber `[]byte(".GET(")`
// bug it was paired with — is structurally impossible. Keeping
// the prefilter for non-Go languages where the regex still
// runs.
"typescript": httpTsJsMarkers,
"javascript": httpTsJsMarkers,
"java": httpJvmMarkers,
"kotlin": httpJvmMarkers,
"dart": {
[]byte("dio."), // lowercased dio instance
[]byte("Dio."), // PascalCase Dio class
[]byte("http."),
[]byte("Router"), // shelf_router `Router()` / `router.`
[]byte("..get("), // shelf_router cascade form
[]byte("..post("), // idem
[]byte("..put("), // idem
[]byte("..delete("),
[]byte("..patch("),
[]byte("..head("),
[]byte("..options("),
},
// rust: the reqwest consumer pattern is `\w+\.(get|post|...)(`,
// and those verbs are universal Rust method calls. Any marker
// tight enough to reject non-HTTP files would also reject
// reqwest consumers, so we leave rust out and run the regex
// scan unconditionally.
"csharp": {
[]byte("[Http"), // attribute routing
[]byte(".Map"), // minimal APIs
[]byte("GetAsync("), // HttpClient consumer idiom
[]byte("PostAsync("), // idem
[]byte("PutAsync("),
[]byte("DeleteAsync("),
[]byte("PatchAsync("),
},
"php": {
[]byte("Route::"),
[]byte("#[Route"),
[]byte("->get("),
[]byte("->post("),
[]byte("->put("),
[]byte("->delete("),
[]byte("->patch("),
},
"swift": {
[]byte(".get("),
[]byte(".post("),
[]byte(".put("),
[]byte(".delete("),
[]byte(".patch("),
[]byte(".request("),
},
}
var httpTsJsMarkers = [][]byte{
[]byte("fetch("),
[]byte("axios"),
[]byte("@Get("),
[]byte("@Post("),
[]byte("@Put("),
[]byte("@Delete("),
[]byte("@Patch("),
[]byte("@Head("),
[]byte("@Options("),
[]byte("app."),
[]byte("router."),
[]byte("fastify."), // Fastify instance verbs / fastify.route({...})
[]byte("server."), // Hapi server.route({...})
[]byte(".route("), // object-config + chained route forms
}
var httpJvmMarkers = [][]byte{
[]byte("Mapping"), // @GetMapping / @PostMapping / @RequestMapping
[]byte("@Path"), // JAX-RS
[]byte("HttpClient"),
[]byte("RestTemplate"),
[]byte("WebClient"),
}
// Extract scans src for HTTP route patterns and returns contracts.
// For Go files this lazily parses the source to get the same AST
// enrichment ExtractWithTree provides — keeps Extract() callers
// (notably legacy tests) on parity with the indexer's tree-aware
// path. Other languages skip the parse since BodyFacts only ships
// for Go in phase 1.
func (h *HTTPExtractor) Extract(filePath string, src []byte, nodes []*graph.Node, edges []*graph.Edge) []Contract {
tree := ParseTreeForLang(detectLanguage(filePath), src)
defer tree.Release()
return h.extract(filePath, src, nodes, edges, tree, nil, "")
}
// ExtractWithTree is the tree-aware variant: enrichment uses BodyFacts
// (AST-based) when tree is non-nil and the language has a registered
// factory, falling back to the regex enricher otherwise. Implements
// TreeAwareExtractor.
func (h *HTTPExtractor) ExtractWithTree(
filePath string,
src []byte,
nodes []*graph.Node,
edges []*graph.Edge,
tree *parser.ParseTree,
) []Contract {
return h.extract(filePath, src, nodes, edges, tree, nil, "")
}
// ExtractWithStore is the store-aware variant: in addition to the tree-aware
// enrichment, Go route path arguments are resolved graph-wide through the
// constant store, so a const-referenced or composite-literal path now mints a
// route. Falls back to a lazily-parsed tree when none is supplied (mirrors
// Extract). Implements StoreAwareExtractor.
func (h *HTTPExtractor) ExtractWithStore(
filePath string,
src []byte,
nodes []*graph.Node,
edges []*graph.Edge,
tree *parser.ParseTree,
store EndpointConstStore,
repoPrefix string,
) []Contract {
if tree == nil {
tree = ParseTreeForLang(detectLanguage(filePath), src)
defer tree.Release()
}
return h.extract(filePath, src, nodes, edges, tree, store, repoPrefix)
}
func (h *HTTPExtractor) extract(
filePath string,
src []byte,
nodes []*graph.Node,
edges []*graph.Edge,
tree *parser.ParseTree,
store EndpointConstStore,
repoPrefix string,
) []Contract {
lang := detectLanguage(filePath)
if markers, ok := httpPrefilterMarkers[lang]; ok && !srcHasAnyMarker(src, markers) {
// A configured client-alias call (e.g. apiGet) carries none of
// the fetch/axios/app. markers, so the prefilter would skip the
// file and the alias pass would never run. Keep the file alive
// when it mentions an alias name; the alias pass below is the
// only work that will then fire. File-based route files (a Next.js
// page.tsx, a SvelteKit +server.ts) and React Router modules carry
// none of those markers either — their route is path/JSX-derived —
// so keep them alive too.
if !srcMentionsAnyAlias(src, h.ClientAliases) && !isFileBasedRouteFile(filePath) &&
!hasReactRouterMarkers(src) && !srcMentionsClientLib(src, httpClientLibraries[lang]) {
return nil
}
}
text := string(src)
lines := strings.Split(text, "\n")
// Pre-sort file nodes by start line for enclosing-function lookup.
fileNodes := filterFileNodes(filePath, nodes)
sort.Slice(fileNodes, func(i, j int) bool {
return fileNodes[i].StartLine < fileNodes[j].StartLine
})
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
// routes are JSX/object-config derived.
if (lang == "typescript" || lang == "javascript") && hasReactRouterMarkers(src) {
out = append(out, h.extractReactRouterRoutes(filePath, text, lines, fileNodes, lang, tree)...)
}
// Go AST-based route detection (Phase 2 of spec-contract-extraction.md).
// When a parse tree is available, walk it for route registrations
// instead of running the Go entries in httpPatterns. Structurally
// distinguishes `[]byte(".GET(")` from `.GET("/users", h)` —
// eliminates the Fiber self-reflexive bug.
if lang == "go" && tree != nil && tree.Tree() != nil {
root := tree.Tree().RootNode()
matches := detectGoRoutesAST(root, src, filePath, repoPrefix, store)
for _, rm := range matches {
c := buildGoRouteContract(rm, filePath, fileNodes, lines, lang, tree, text, src)
out = append(out, c)
}
}
for _, pat := range httpPatterns {
if !patternMatchesLang(pat, lang) {
continue
}
// Phase 2: skip the Go PROVIDER regex entries when a tree is
// available — the AST loop above already handled them. The
// Go consumer regexes (http.Get / http.Post) still run since
// the AST detector only covers route registrations, not
// HTTP client calls. The Go entries stay in httpPatterns
// for the no-tree code path (the indexer's incremental
// re-walk can't always get a tree).
if lang == "go" && tree != nil && tree.Tree() != nil && pat.role == RoleProvider {
continue
}
for _, m := range pat.re.FindAllStringSubmatchIndex(text, -1) {
lineNum := lineAtOffset(lines, m[0])
method := pat.method
path := ""
if pat.methodGrp > 0 {
method = strings.ToUpper(text[m[pat.methodGrp*2]:m[pat.methodGrp*2+1]])
}
path = text[m[pat.pathGrp*2]:m[pat.pathGrp*2+1]]
// Consumer literals that point at a filesystem location, a
// config file, or a static asset are not HTTP API consumers —
// drop them before they mint a spurious consumer contract. Only
// rooted "/..." literals are gated; relative and
// template-interpolated client calls keep their existing
// behaviour so legitimate dynamic consumers are not lost.
if pat.role == RoleConsumer && strings.HasPrefix(path, "/") &&
(!IsLikelyHTTPRouteLiteral(path, "") || IsStaticAssetPath(path)) {
continue
}
// Go's net/http stdlib mux treats a trailing slash as a
// subtree match — `mux.HandleFunc("POST /v1/tools/", h)`
// serves every POST under /v1/tools/. Without this fix
// NormalizeHTTPPath strips the trailing slash and the ID
// becomes "http::POST::/v1/tools", which never pairs with
// per-route consumers like `fetch('/v1/tools/${name}')`
// (normalised to /v1/tools/{p1}). Append a parametric tail
// so the subtree shape matches parametric consumers in the
// same workspace. Limit to net/http: gin/echo/chi/fiber
// treat trailing slash as a distinct literal route, not a
// subtree.
subtree := false
if pat.role == RoleProvider && lang == "go" && pat.framework == "net/http" &&
len(path) > 1 && strings.HasSuffix(path, "/") {
path = strings.TrimRight(path, "/") + "/{rest}"
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)
symbolID := findEnclosingSymbol(fileNodes, lineNum)
// Provider patterns that also capture the handler identifier
// re-point SymbolID at the actual handler function in the
// same file. Two forms handled:
// 1. Bare handler: r.GET("/users", listUsers)
// → handlerGrp captures "listUsers", resolve directly.
// 2. Middleware-wrapped: mux.HandleFunc("POST /x",
// WithAuth(auth, h.CreateTuck)) — handlerGrp grabs
// "WithAuth" which is a wrapper. Walk forward from
// the end of the handlerGrp match, through the rest
// of the call's balanced parens, and pick the LAST
// identifier (or method reference like h.CreateTuck)
// that resolves to a function in this file. That's
// the innermost handler — what "trace a request"
// actually wants to land on.
var handlerIdent, handlerTrail, handlerClass string
if pat.handlerGrp > 0 && pat.role == RoleProvider {
gStart := m[pat.handlerGrp*2]
gEnd := m[pat.handlerGrp*2+1]
if gStart >= 0 && gEnd > gStart {
handlerName := text[gStart:gEnd]
handlerIdent = handlerName
// Always capture the full call-trail (every
// argument between the HandleFunc parens) so a
// later module-wide pass can enumerate handler
// candidates — the narrow `\w+` regex capture
// above stops at the first `.` in `h.ServeArchive`
// and misses wrappers in `WithAuth(h.Foo)`.
// callTrailSlice walks forward from the start of the
// HandleFunc match to the matching `)`; passing m[0]
// (match start) gets us the full args slice. Passing
// m[1] (match end) would search past every paren we
// care about and return the empty string.
handlerTrail = callTrailSlice(text, m[0])
if hID := resolveHandlerIdent(fileNodes, handlerName); hID != "" {
symbolID = hID
} else if hID := findInnermostResolvableHandler(fileNodes, handlerTrail); hID != "" {
symbolID = hID
}
}
}
// Inline arrow/function route handlers (Express/Fastify/Koa) have
// no named symbol to bind. The JS/TS extractor materialises a
// synthetic handler node anchored to the route call line and
// attributes the handler body's application calls to it; anchor the
// route Contract's SymbolID to that node so the route connects
// through the anonymous handler to the services it invokes.
if symbolID == "" && pat.role == RoleProvider &&
(lang == "typescript" || lang == "javascript") &&
strings.Contains(lines[lineNum-1], "=>") {
symbolID = filePath + "::express-handler@" + strconv.Itoa(lineNum)
}
// Backend frameworks (Laravel / Rails / Spring / JAX-RS / ASP.NET)
// bind the controller action the route dispatches to: same-file for
// annotation-preceded handlers, and a stamped identifier that the
// indexer's module-wide pass resolves cross-file for route files
// wiring controllers in sibling files. Receiver-aware, so the bound
// action is the named controller's, not a same-named one elsewhere.
if pat.role == RoleProvider && isBackendHandlerFramework(pat.framework) {
if sid, hIdent, hClass := bindBackendHandler(pat.framework, lines[lineNum-1], lineNum-1, lines, fileNodes); hIdent != "" {
if sid != "" {
symbolID = sid
}
handlerIdent = hIdent
handlerClass = hClass
}
}
meta := map[string]any{
"method": method,
"path": normPath,
"framework": pat.framework,
}
// Preserve the developer-written parameter names alongside
// the positional path (e.g. "/v1/sessions/{id}" exposes
// path_param_names=["id"] while path stays "{p1}"). The
// dashboard shows these for readability; drift detection
// uses them to flag mismatches when a provider renames a
// slot but consumers haven't picked it up.
if len(origNames) > 0 {
meta["path_param_names"] = origNames
}
// Keep the raw handler identifier + the full call-trail
// so a later module-wide pass can look handlers up
// globally when file-scoped resolution failed. The
// trail carries every candidate (wrappers + inner
// handler) so we can pick the innermost-resolvable one
// across repos.
if handlerIdent != "" {
meta["handler_ident"] = handlerIdent
}
if handlerClass != "" {
// The declaring controller — lets the module-wide pass pick the
// receiver-correct action when several controllers share a verb.
meta["handler_class"] = handlerClass
}
if handlerTrail != "" {
meta["handler_trail"] = handlerTrail
}
if subtree {
meta["subtree"] = true
}
c := Contract{
ID: contractID,
Type: ContractHTTP,
Role: pat.role,
SymbolID: symbolID,
FilePath: filePath,
Line: lineNum,
Meta: meta,
Confidence: pat.confidence,
}
// Second pass: pull request/response types, query params,
// and status codes out of the handler body (provider) or
// the call-site window (consumer). The enricher mutates
// c.Meta in place and sets "schema_source". When a parse
// tree is available the AST overlay runs after the regex
// pass and overrides Meta keys it can confidently produce.
EnrichHTTPContractWithTree(&c, lines, fileNodes, lang, tree)
out = append(out, c)
}
}
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
// Detect pre-filter, and is crash-isolated, so one panicking pass does not
// abort the rest. A new framework registers via RegisterFrameworkRoutePass
// with no edits here.
out = append(out, runFrameworkRoutePasses(&RouteExtractCtx{
FilePath: filePath, Src: src, Text: text, Lines: lines,
FileNodes: fileNodes, Lang: lang, Tree: tree, H: h,
})...)
// Configurable HTTP-client wrapper aliases. Calls to a
// project-named wrapper (e.g. apiGet('/users')) become consumer
// contracts even though no built-in fetch/axios pattern matched.
// Scoped to the TS/JS family — the alias mechanism mirrors the
// fetch/axios consumer heuristics, which are TS/JS only. Stays
// hard-wired: its gate reads h.ClientAliases (instance state), not src.
if len(h.ClientAliases) > 0 && (lang == "typescript" || lang == "javascript") {
out = append(out, h.detectClientAliasConsumers(filePath, text, lines, fileNodes, lang, tree)...)
}
// Import-gated HTTP client-library consumers for the languages whose client
// surface is bound to a resolved import rather than a call-text substring
// (python/rust/ruby/php/java/kotlin/scala). Runs after the regex / framework
// passes so the canonical contract IDs match the provider side.
out = append(out, h.detectClientLibConsumers(filePath, lang, src, lines, fileNodes, tree, store, repoPrefix)...)
// Preserve the developer-written path and stamp the per-reference route
// kind on every HTTP contract.
for i := range out {
if out[i].Type == ContractHTTP {
stampHTTPRouteShape(out[i].Meta)
}
}
return out
}
// RouteKindForPath classifies a normalized route path by its shape: a
// catch-all/subtree route is "wildcard", one with path parameters is
// "parametric", and a fully literal route is "static".
func RouteKindForPath(normPath string) string {
switch {
case strings.Contains(normPath, "{rest}") || strings.Contains(normPath, "*"):
return "wildcard"
case strings.Contains(normPath, "{"):
return "parametric"
default:
return "static"
}
}
// OriginalRoutePath reconstructs the developer-written path from a
// positional-normalized path ("/v1/sessions/{p1}") and its captured original
// parameter names (["id"]) → "/v1/sessions/{id}".
func OriginalRoutePath(normPath string, names []string) string {
out := normPath
for i, name := range names {
out = strings.Replace(out, fmt.Sprintf("{p%d}", i+1), "{"+name+"}", 1)
}
return out
}
// stampHTTPRouteShape records the developer-facing original_path and the
// per-reference route_kind on an HTTP contract's Meta, derived from its
// normalized path and original parameter names.
func stampHTTPRouteShape(meta map[string]any) {
if meta == nil {
return
}
normPath, _ := meta["path"].(string)
if normPath == "" {
return
}
names, _ := meta["path_param_names"].([]string)
meta["original_path"] = OriginalRoutePath(normPath, names)
meta["route_kind"] = RouteKindForPath(normPath)
}
// detectLanguage infers the language from a file extension.
func detectLanguage(filePath string) string {
switch {
case strings.HasSuffix(filePath, ".go"):
return "go"
case strings.HasSuffix(filePath, ".ts"), strings.HasSuffix(filePath, ".tsx"):
return "typescript"
case strings.HasSuffix(filePath, ".js"), strings.HasSuffix(filePath, ".jsx"):
return "javascript"
case strings.HasSuffix(filePath, ".py"):
return "python"
case strings.HasSuffix(filePath, ".java"):
return "java"
case strings.HasSuffix(filePath, ".kt"), strings.HasSuffix(filePath, ".kts"):
return "kotlin"
case strings.HasSuffix(filePath, ".dart"):
return "dart"
case strings.HasSuffix(filePath, ".rs"):
return "rust"
case strings.HasSuffix(filePath, ".swift"):
return "swift"
case strings.HasSuffix(filePath, ".cs"):
return "csharp"
case strings.HasSuffix(filePath, ".rb"):
return "ruby"
case strings.HasSuffix(filePath, ".php"):
return "php"
case strings.HasSuffix(filePath, ".scala"), strings.HasSuffix(filePath, ".sc"):
return "scala"
case strings.HasSuffix(filePath, ".ex"), strings.HasSuffix(filePath, ".exs"):
return "elixir"
case strings.HasSuffix(filePath, ".astro"):
return "astro"
case strings.HasSuffix(filePath, ".svelte"):
return "svelte"
case strings.HasSuffix(filePath, ".vue"):
return "vue"
default:
return ""
}
}
// patternMatchesLang returns true if the pattern applies to the given language.
func patternMatchesLang(p httpPattern, lang string) bool {
if len(p.languages) == 0 {
return true
}
for _, l := range p.languages {
if l == lang {
return true
}
}
return false
}
// lineAtOffset returns the 1-based line number for the given byte offset.
func lineAtOffset(lines []string, offset int) int {
pos := 0
for i, l := range lines {
end := pos + len(l) + 1 // +1 for newline
if offset < end {
return i + 1
}
pos = end
}
return len(lines)
}
// filterFileNodes returns only nodes that belong to the given file.
func filterFileNodes(filePath string, nodes []*graph.Node) []*graph.Node {
var out []*graph.Node
for _, n := range nodes {
if n.FilePath == filePath {
out = append(out, n)
}
}
return out
}
// findEnclosingSymbol returns the ID of the nearest function/method that
// encloses the given line number. Falls back to "" if none found.
//
// Strict containment (StartLine ≤ line ≤ EndLine) is preferred, but some
// language extractors (notably Dart's tree-sitter path) report EndLine as
// the signature line rather than the closing brace, so a call on the very
// next line wouldn't match. When strict containment fails, fall back to
// the closest-preceding symbol whose EndLine ≥ (line - closeProximity) —
// the call is most likely inside its body. "" still means nothing's even
// near enough.
func findEnclosingSymbol(sortedNodes []*graph.Node, line int) string {
best := ""
bestStart := 0
for _, n := range sortedNodes {
if n.Kind != graph.KindFunction && n.Kind != graph.KindMethod {
continue
}
if n.StartLine <= line && n.EndLine >= line && n.StartLine >= bestStart {
best = n.ID
bestStart = n.StartLine
}
}
if best != "" {
return best
}
// Fallback: the closest function/method whose declaration precedes
// the line — tolerates off-by-N EndLine reports from extractors that
// don't compute the closing brace.
fallback := ""
fallbackStart := 0
for _, n := range sortedNodes {
if n.Kind != graph.KindFunction && n.Kind != graph.KindMethod {
continue
}
if n.StartLine <= line && n.StartLine > fallbackStart {
fallback = n.ID
fallbackStart = n.StartLine
}
}
return fallback
}
// findFunctionByName returns the ID of a function or method declared in the
// same file with the given short name (e.g. "listUsers"). Used by the HTTP
// provider extractor to re-point a contract's SymbolID at its handler
// function when the pattern captures it.
func findFunctionByName(fileNodes []*graph.Node, name string) string {
for _, n := range fileNodes {
if n.Kind != graph.KindFunction && n.Kind != graph.KindMethod {
continue
}
if n.Name == name {
return n.ID
}
}
return ""
}
// resolveHandlerIdent resolves a handler identifier captured by a
// provider-pattern regex. Accepts bare "listUsers" (function name)
// and method-expression "h.CreateTuck" (dot-qualified) — the latter
// common when routes are registered on a receiver. The method-name
// after the dot is used for the lookup, so `h.CreateTuck` resolves
// to a method CreateTuck in the same file regardless of receiver
// variable name.
func resolveHandlerIdent(fileNodes []*graph.Node, ident string) string {
if ident == "" {
return ""
}
if i := strings.LastIndex(ident, "."); i >= 0 {
ident = ident[i+1:]
}
return findFunctionByName(fileNodes, ident)
}
// callTrailSlice returns the byte slice that starts at the HandleFunc
// call's opening "(" (found by the regex at matchStart) and ends at
// the matching balanced close ")". Used to scan past a middleware
// wrapper for an inner handler identifier. Returns empty when the
// call can't be balanced (which only happens on truncated or invalid
// source — production files are fine).
func callTrailSlice(src string, matchStart int) string {
// Seek forward from matchStart to the first '(' — that's the
// opening paren of the HandleFunc call. The regex's m[0] lands
// at the start of the "HandleFunc" token.
openIdx := -1
for i := matchStart; i < len(src); i++ {
if src[i] == '(' {
openIdx = i
break
}
if src[i] == '\n' {
return ""
}
}
if openIdx < 0 {
return ""
}
depth := 0
i := openIdx
for i < len(src) {
switch src[i] {
case '(':
depth++
i++
case ')':
depth--
if depth == 0 {
return src[openIdx+1 : i]
}
i++
case '"', '\'', '`':
q := src[i]
i++
for i < len(src) && src[i] != q {
if src[i] == '\\' && i+1 < len(src) {
i += 2
continue
}
i++
}
if i < len(src) {
i++
}
default:
i++
}
}
return ""
}
// handlerCandidateRE captures every bare identifier or `recv.Method`
// style expression in the call-trail. Tight enough to skip keywords
// like "context" or "nil" only by not resolving them to a file-local
// function — the caller filters via findFunctionByName.
var handlerCandidateRE = regexp.MustCompile(`\b([A-Za-z_]\w*(?:\.\w+)?)\b`)
// HandlerCandidatesInTrail enumerates every identifier / receiver.method
// reference inside a HandleFunc call-trail in source order. The
// indexer's cross-file resolution pass uses this to pick the
// innermost-resolvable handler (last candidate that resolves to a
// real function or method globally).
func HandlerCandidatesInTrail(trail string) []string {
matches := handlerCandidateRE.FindAllStringSubmatch(trail, -1)
out := make([]string, 0, len(matches))
for _, m := range matches {
if len(m) > 1 && m[1] != "" {
out = append(out, m[1])
}
}
return out
}
// findInnermostResolvableHandler walks the call trail and returns the
// LAST identifier that resolves to a function or method declared in
// the same file. For `WithAuth(auth, h.CreateTuck)` this is
// `h.CreateTuck` (resolves to CreateTuck method); WithAuth and auth
// fail to resolve (not file-local). Returns "" if no candidate
// resolves.
func findInnermostResolvableHandler(fileNodes []*graph.Node, trail string) string {
matches := handlerCandidateRE.FindAllStringSubmatch(trail, -1)
var best string
for _, m := range matches {
if id := resolveHandlerIdent(fileNodes, m[1]); id != "" {
best = id
}
}
return best
}