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

1004 lines
29 KiB
Go

package contracts
import (
"testing"
"github.com/zzet/gortex/internal/graph"
)
func makeNodes(filePath string, fns []struct {
name string
start, end int
}) []*graph.Node {
var nodes []*graph.Node
for _, f := range fns {
nodes = append(nodes, &graph.Node{
ID: filePath + "::" + f.name,
Kind: graph.KindFunction,
Name: f.name,
FilePath: filePath,
StartLine: f.start,
EndLine: f.end,
})
}
return nodes
}
// TestHTTPExtractor_Go_Gin_HandlerResolution exercises the T1.3 path: when
// the pattern captures the handler identifier (e.g. "listUsers" in
// r.GET("/users", listUsers)) AND that identifier resolves to a function
// node in the same file, the Contract's SymbolID is the handler — not the
// enclosing setupRoutes function. Cross-service traversals landing on the
// provider side then reach business logic, not the router glue.
//
// When the handler doesn't resolve (e.g. lambda, method expr, different
// file) the code falls back to enclosing-symbol behavior — covered by
// TestHTTPExtractor_Go_Gin above, which deliberately omits handler nodes.
func TestHTTPExtractor_Go_Gin_HandlerResolution(t *testing.T) {
src := []byte(`package main
import "github.com/gin-gonic/gin"
func setupRoutes(r *gin.Engine) {
r.GET("/api/users", listUsers)
r.POST("/api/users", createUser)
}
func listUsers() {}
func createUser() {}
`)
nodes := makeNodes("main.go", []struct {
name string
start, end int
}{
{"setupRoutes", 5, 8},
{"listUsers", 10, 10},
{"createUser", 11, 11},
})
ext := &HTTPExtractor{}
contracts := ext.Extract("main.go", src, nodes, nil)
byPath := map[string]Contract{}
for _, c := range contracts {
byPath[c.ID] = c
}
get := byPath["http::GET::/api/users"]
if get.SymbolID != "main.go::listUsers" {
t.Errorf("GET handler: expected SymbolID=main.go::listUsers (handler), got %q", get.SymbolID)
}
post := byPath["http::POST::/api/users"]
if post.SymbolID != "main.go::createUser" {
t.Errorf("POST handler: expected SymbolID=main.go::createUser (handler), got %q", post.SymbolID)
}
}
func TestHTTPExtractor_Go_Gin(t *testing.T) {
src := []byte(`package main
import "github.com/gin-gonic/gin"
func setupRoutes(r *gin.Engine) {
r.GET("/api/users", listUsers)
r.POST("/api/users", createUser)
r.GET("/api/users/:id", getUser)
r.DELETE("/api/users/:id", deleteUser)
}
`)
nodes := makeNodes("main.go", []struct {
name string
start, end int
}{
{"setupRoutes", 5, 10},
})
ext := &HTTPExtractor{}
contracts := ext.Extract("main.go", src, nodes, nil)
if len(contracts) != 4 {
t.Fatalf("expected 4 contracts, got %d", len(contracts))
}
// Check first contract.
c := contracts[0]
if c.Type != ContractHTTP {
t.Errorf("expected type http, got %s", c.Type)
}
if c.Role != RoleProvider {
t.Errorf("expected role provider, got %s", c.Role)
}
if c.Meta["method"] != "GET" {
t.Errorf("expected method GET, got %s", c.Meta["method"])
}
if c.Meta["path"] != "/api/users" {
t.Errorf("expected path /api/users, got %s", c.Meta["path"])
}
if c.ID != "http::GET::/api/users" {
t.Errorf("expected ID http::GET::/api/users, got %s", c.ID)
}
if c.SymbolID != "main.go::setupRoutes" {
t.Errorf("expected symbol main.go::setupRoutes, got %s", c.SymbolID)
}
if c.Confidence != 0.9 {
t.Errorf("expected confidence 0.9, got %f", c.Confidence)
}
// Check path param normalisation. Param names collapse to
// positional {p1}, {p2}, ... so a provider's {id} and a
// consumer's {userId} hash to the same contract ID.
c3 := contracts[2]
if c3.Meta["path"] != "/api/users/{p1}" {
t.Errorf("expected normalised path /api/users/{p1}, got %s", c3.Meta["path"])
}
}
func TestHTTPExtractor_TypeScript_Express(t *testing.T) {
src := []byte(`
import express from 'express';
const app = express();
function registerRoutes() {
app.get('/api/products', listProducts);
app.post('/api/products', createProduct);
app.get('/api/products/:id', getProduct);
}
`)
nodes := makeNodes("routes.ts", []struct {
name string
start, end int
}{
{"registerRoutes", 5, 9},
})
ext := &HTTPExtractor{}
contracts := ext.Extract("routes.ts", src, nodes, nil)
if len(contracts) != 3 {
t.Fatalf("expected 3 contracts, got %d", len(contracts))
}
c := contracts[0]
if c.Meta["framework"] != "express" {
t.Errorf("expected framework express, got %s", c.Meta["framework"])
}
if c.Meta["method"] != "GET" {
t.Errorf("expected method GET, got %s", c.Meta["method"])
}
if c.Meta["path"] != "/api/products" {
t.Errorf("expected path /api/products, got %s", c.Meta["path"])
}
}
func TestHTTPExtractor_Python_FastAPI(t *testing.T) {
src := []byte(`
from fastapi import FastAPI
app = FastAPI()
@app.get("/api/items")
def list_items():
return []
@app.post("/api/items")
def create_item(item: Item):
return item
@app.get("/api/items/{item_id}")
def get_item(item_id: int):
return item_id
`)
nodes := makeNodes("main.py", []struct {
name string
start, end int
}{
{"list_items", 6, 7},
{"create_item", 10, 11},
{"get_item", 14, 15},
})
ext := &HTTPExtractor{}
contracts := ext.Extract("main.py", src, nodes, nil)
if len(contracts) != 3 {
t.Fatalf("expected 3 contracts, got %d", len(contracts))
}
if contracts[0].Meta["method"] != "GET" {
t.Errorf("expected GET, got %s", contracts[0].Meta["method"])
}
if contracts[0].Meta["path"] != "/api/items" {
t.Errorf("expected /api/items, got %s", contracts[0].Meta["path"])
}
if contracts[0].Meta["framework"] != "fastapi/flask" {
t.Errorf("expected fastapi/flask, got %s", contracts[0].Meta["framework"])
}
}
func TestHTTPExtractor_Java_Spring(t *testing.T) {
src := []byte(`
@RestController
@RequestMapping("/api")
public class UserController {
@GetMapping("/users")
public List<User> listUsers() {
return userService.findAll();
}
@PostMapping("/users")
public User createUser(@RequestBody User user) {
return userService.save(user);
}
@DeleteMapping("/users/{id}")
public void deleteUser(@PathVariable Long id) {
userService.delete(id);
}
}
`)
nodes := makeNodes("UserController.java", []struct {
name string
start, end int
}{
{"listUsers", 6, 8},
{"createUser", 11, 13},
{"deleteUser", 16, 18},
})
ext := &HTTPExtractor{}
contracts := ext.Extract("UserController.java", src, nodes, nil)
// Should detect @RequestMapping + @GetMapping + @PostMapping + @DeleteMapping
if len(contracts) < 3 {
t.Fatalf("expected at least 3 contracts, got %d", len(contracts))
}
// Find the GetMapping contract.
found := false
for _, c := range contracts {
if c.Meta["method"] == "GET" && c.Meta["path"] == "/users" {
found = true
if c.Meta["framework"] != "spring" {
t.Errorf("expected spring framework, got %s", c.Meta["framework"])
}
}
}
if !found {
t.Error("did not find GET /users contract from @GetMapping")
}
}
func TestHTTPExtractor_Consumers(t *testing.T) {
src := []byte(`package main
import "net/http"
func callAPI() {
http.Get("http://service-b/api/users")
http.NewRequest("POST", "http://service-b/api/orders", nil)
}
`)
nodes := makeNodes("client.go", []struct {
name string
start, end int
}{
{"callAPI", 5, 8},
})
ext := &HTTPExtractor{}
contracts := ext.Extract("client.go", src, nodes, nil)
if len(contracts) != 2 {
t.Fatalf("expected 2 contracts, got %d", len(contracts))
}
for _, c := range contracts {
if c.Role != RoleConsumer {
t.Errorf("expected consumer role, got %s", c.Role)
}
}
}
func TestHTTPExtractor_JS_Consumers(t *testing.T) {
src := []byte(`
async function fetchData() {
const users = await fetch('/api/users');
const result = await axios.post('/api/orders', data);
}
`)
nodes := makeNodes("api.js", []struct {
name string
start, end int
}{
{"fetchData", 2, 5},
})
ext := &HTTPExtractor{}
contracts := ext.Extract("api.js", src, nodes, nil)
if len(contracts) != 2 {
t.Fatalf("expected 2 contracts, got %d", len(contracts))
}
// fetch is confidence 0.7, axios is 0.9
for _, c := range contracts {
if c.Role != RoleConsumer {
t.Errorf("expected consumer, got %s", c.Role)
}
}
}
func TestHTTPExtractor_Python_Consumers(t *testing.T) {
src := []byte(`
import requests
def call_service():
resp = requests.get("http://service/api/users")
resp2 = requests.post("http://service/api/orders")
`)
nodes := makeNodes("client.py", []struct {
name string
start, end int
}{
{"call_service", 4, 6},
})
ext := &HTTPExtractor{}
contracts := ext.Extract("client.py", src, nodes, nil)
if len(contracts) != 2 {
t.Fatalf("expected 2 contracts, got %d", len(contracts))
}
}
func TestNormalizeHTTPPath(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
// Path parameters are rewritten to positional names ({p1},
// {p2}, ...) so cross-repo contract IDs match regardless of
// whether provider and consumer teams picked different names
// for the same slot — the well-known `{wid}` vs `{workspaceId}`
// mismatch that used to appear as two orphan contracts.
{"gin-style param", "/users/:id", "/users/{p1}"},
{"typed angle param", "/users/<int:id>", "/users/{p1}"},
{"canonical brace param", "/users/{id}", "/users/{p1}"},
{"multiple params rename positionally", "/workspaces/{wid}/tags/{id}", "/workspaces/{p1}/tags/{p2}"},
{"consumer name variant hashes the same", "/workspaces/{workspaceId}/tags/{id}", "/workspaces/{p1}/tags/{p2}"},
{"trailing slash + quotes", `"/api/v1/items/"`, "/api/v1/items"},
{"missing leading slash", "api/users", "/api/users"},
{"root", "/", "/"},
// T1.2: scheme+authority stripping.
{"http scheme", "http://api.example.com/v1/users", "/v1/users"},
{"https scheme with port", "https://api.example.com:443/v1/users", "/v1/users"},
{"scheme only", "http://api.example.com", "/"},
// T1.1: JS/TS template-literal base-URL stripping.
{"leading tpl placeholder", "${API_URL}/v1/tucks", "/v1/tucks"},
{"leading slash then placeholder", "/${TUCK_API_URL}/v1/tucks", "/v1/tucks"},
{"dotted placeholder", "${process.env.API_URL}/v1/users", "/v1/users"},
{"inline placeholder becomes param", "/v1/users/${id}", "/v1/users/{p1}"},
{"base + inline param", "${BASE}/v1/users/${id}/tags", "/v1/users/{p1}/tags"},
{"tpl inside host", "https://${HOST}/v1/users", "/v1/users"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := NormalizeHTTPPath(tt.input)
if got != tt.expected {
t.Errorf("NormalizeHTTPPath(%q) = %q, want %q", tt.input, got, tt.expected)
}
})
}
}
// TestHTTPExtractor_Go_StdlibMux_NoLegacyDuplicate guards against the
// regression where both the Go 1.22+ pattern and the legacy HandleFunc
// pattern matched the same route, producing duplicate contracts
// (http::POST::/v1/tucks AND http::ANY::/POST /v1/tucks for a single
// mux.HandleFunc("POST /v1/tucks", h)). The legacy pattern's path
// capture now requires a leading "/", which the 1.22+ form never has
// (it starts with the verb), so only the specific extractor fires.
func TestHTTPExtractor_Go_StdlibMux_NoLegacyDuplicate(t *testing.T) {
src := []byte(`package main
import "net/http"
func wire(mux *http.ServeMux, h *Handler) {
mux.HandleFunc("POST /v1/tucks", h.CreateTuck)
}
`)
nodes := makeNodes("main.go", []struct {
name string
start, end int
}{
{"wire", 5, 7},
{"CreateTuck", 9, 9},
})
ext := &HTTPExtractor{}
contracts := ext.Extract("main.go", src, nodes, nil)
seen := map[string]int{}
for _, c := range contracts {
seen[c.ID]++
}
if seen["http::ANY::/POST /v1/tucks"] > 0 {
t.Errorf("legacy HandleFunc pattern double-matched the 1.22 form: %v", seen)
}
if seen["http::POST::/v1/tucks"] != 1 {
t.Errorf("expected exactly 1 http::POST::/v1/tucks contract, got %d (all: %v)",
seen["http::POST::/v1/tucks"], seen)
}
}
// TestHTTPExtractor_Go_HandlerThroughMiddleware guards against the
// regression where routes registered as
//
// mux.HandleFunc("POST /v1/tucks", WithAuth(auth, h.CreateTuck))
//
// landed with SymbolID = "WithAuth" (the middleware wrapper) or the
// enclosing RegisterRoutes function, rather than the actual handler
// h.CreateTuck. The extractor now walks the balanced-paren tail of
// the call expression, picks the innermost identifier that resolves
// to a function/method in the same file — correctly landing on
// CreateTuck.
func TestHTTPExtractor_Go_HandlerThroughMiddleware(t *testing.T) {
src := []byte(`package main
import "net/http"
func wire(mux *http.ServeMux, h *Handler) {
mux.HandleFunc("POST /v1/tucks", WithAuth(auth, h.CreateTuck))
mux.HandleFunc("GET /v1/tucks/{id}", WithAuth(auth, h.GetTuck))
mux.HandleFunc("DELETE /v1/tucks/{id}", WithAuth(auth, WithAudit(audit, h.DeleteTuck)))
}
`)
nodes := makeNodes("main.go", []struct {
name string
start, end int
}{
{"wire", 5, 9},
{"CreateTuck", 12, 12},
{"GetTuck", 14, 14},
{"DeleteTuck", 16, 16},
})
ext := &HTTPExtractor{}
contracts := ext.Extract("main.go", src, nodes, nil)
bySymbol := map[string]string{}
for _, c := range contracts {
if c.Role != RoleProvider {
continue
}
bySymbol[c.ID] = c.SymbolID
}
want := map[string]string{
"http::POST::/v1/tucks": "main.go::CreateTuck",
"http::GET::/v1/tucks/{p1}": "main.go::GetTuck",
"http::DELETE::/v1/tucks/{p1}": "main.go::DeleteTuck",
}
for id, wantSym := range want {
gotSym, ok := bySymbol[id]
if !ok {
t.Errorf("missing provider contract %s", id)
continue
}
if gotSym != wantSym {
t.Errorf("%s: SymbolID want %s, got %s (dropped to enclosing or wrapper name)",
id, wantSym, gotSym)
}
}
}
// TestHTTPExtractor_Dart_Consumers covers T2.1: Dart HTTP client patterns
// (dio and package:http) now produce consumer contracts. Exercised via
// short snippets resembling the shape of tuck_app's TuckApiClient
// methods, including Dart's bare-$id interpolation style that
// NormalizeHTTPPath collapses to a positional {p1}.
// TestHTTPExtractor_Go_StdlibMux_1_22 covers the Go 1.22+ stdlib mux
// pattern where the HTTP method is embedded in the pattern string as
// "METHOD /path". Without splitting it, the contract ID ended up as
// "http::ANY::/DELETE /v1/..." which never pairs with a consumer
// "http::DELETE::/v1/...". core-api uses this pattern on 40+ routes;
// this is the regression that kept them all orphan.
func TestHTTPExtractor_Go_StdlibMux_1_22(t *testing.T) {
src := []byte(`package main
import "net/http"
func wire(mux *http.ServeMux, h *Handler) {
mux.HandleFunc("GET /v1/tucks", h.ListTucks)
mux.HandleFunc("POST /v1/tucks", h.CreateTuck)
mux.HandleFunc("DELETE /v1/tucks/{id}", h.DeleteTuck)
mux.HandleFunc("PATCH /v1/tucks/{id}/progress", h.UpdateProgress)
}
`)
nodes := makeNodes("main.go", []struct {
name string
start, end int
}{
{"wire", 5, 10},
})
ext := &HTTPExtractor{}
contracts := ext.Extract("main.go", src, nodes, nil)
byID := make(map[string]Contract)
for _, c := range contracts {
byID[c.ID] = c
}
want := []struct {
id, method, path string
}{
{"http::GET::/v1/tucks", "GET", "/v1/tucks"},
{"http::POST::/v1/tucks", "POST", "/v1/tucks"},
{"http::DELETE::/v1/tucks/{p1}", "DELETE", "/v1/tucks/{p1}"},
{"http::PATCH::/v1/tucks/{p1}/progress", "PATCH", "/v1/tucks/{p1}/progress"},
}
for _, w := range want {
c, ok := byID[w.id]
if !ok {
t.Errorf("missing contract %s; have: %v", w.id, keysOf(byID))
continue
}
if c.Role != RoleProvider {
t.Errorf("%s: role want provider, got %s", w.id, c.Role)
}
if c.Meta["method"] != w.method {
t.Errorf("%s: method want %s, got %v", w.id, w.method, c.Meta["method"])
}
if c.Meta["path"] != w.path {
t.Errorf("%s: path want %s, got %v", w.id, w.path, c.Meta["path"])
}
}
}
// TestHTTPExtractor_Go_StdlibMux_Subtree pins the trailing-slash
// rewrite. `mux.HandleFunc("POST /v1/tools/", h)` is Go's net/http
// subtree-match form — every POST under /v1/tools/ hits this handler.
// The contract ID must encode that as a parametric tail so it pairs
// with consumers calling per-route paths like /v1/tools/{name} from a
// sibling repo in the same workspace.
func TestHTTPExtractor_Go_StdlibMux_Subtree(t *testing.T) {
src := []byte(`package main
import "net/http"
func wire(mux *http.ServeMux, h *Handler) {
mux.HandleFunc("POST /v1/tools/", h.handleToolCall)
mux.HandleFunc("GET /v1/static/", h.handleStatic)
mux.HandleFunc("GET /v1/health", h.handleHealth)
mux.Handle("/", root)
}
`)
nodes := makeNodes("main.go", []struct {
name string
start, end int
}{
{"wire", 5, 10},
})
ext := &HTTPExtractor{}
contracts := ext.Extract("main.go", src, nodes, nil)
byID := make(map[string]Contract)
for _, c := range contracts {
byID[c.ID] = c
}
// Subtree handlers get a parametric tail and a meta.subtree marker.
for _, id := range []string{"http::POST::/v1/tools/{p1}", "http::GET::/v1/static/{p1}"} {
c, ok := byID[id]
if !ok {
t.Errorf("missing subtree contract %s; have: %v", id, keysOf(byID))
continue
}
if c.Meta["subtree"] != true {
t.Errorf("%s: meta.subtree want true, got %v", id, c.Meta["subtree"])
}
}
// Literal-path handlers stay literal — no parametric tail, no
// subtree marker.
if c, ok := byID["http::GET::/v1/health"]; !ok {
t.Errorf("literal contract /v1/health was rewritten; have: %v", keysOf(byID))
} else if c.Meta["subtree"] == true {
t.Errorf("/v1/health: meta.subtree should be unset, got true")
}
// Root-only Handle("/", root) must NOT become "/{p1}" — that's
// not a subtree, it's the catchall route.
if _, ok := byID["http::ANY::/{p1}"]; ok {
t.Errorf("root path / was wrongly rewritten as subtree; have: %v", keysOf(byID))
}
}
// TestHTTPExtractor_Go_Subtree_PairsWithParametricConsumer is the
// end-to-end pin for the cross-repo bridge that motivated the
// trailing-slash rewrite. A `gortex` provider declares
// `mux.HandleFunc("POST /v1/tools/", h)` (subtree) and a `web`
// consumer in the same workspace calls `fetch('/v1/tools/${name}')`
// (parametric). Before the fix the IDs were `/v1/tools` vs
// `/v1/tools/{p1}` and the matcher kept both as orphans. After the
// fix they share `/v1/tools/{p1}` and pair as a CrossRepo link.
func TestHTTPExtractor_Go_Subtree_PairsWithParametricConsumer(t *testing.T) {
providerSrc := []byte(`package main
import "net/http"
func wire(mux *http.ServeMux, h *Handler) {
mux.HandleFunc("POST /v1/tools/", h.handleToolCall)
}
`)
providerNodes := makeNodes("server.go", []struct {
name string
start, end int
}{
{"wire", 5, 7},
})
consumerSrc := []byte(`async function callTool(name) {
return fetch(` + "`/v1/tools/${name}`" + `, { method: 'POST' });
}
`)
consumerNodes := makeNodes("api.ts", []struct {
name string
start, end int
}{
{"callTool", 1, 3},
})
ext := &HTTPExtractor{}
provContracts := ext.Extract("server.go", providerSrc, providerNodes, nil)
consContracts := ext.Extract("api.ts", consumerSrc, consumerNodes, nil)
reg := NewRegistry()
for _, c := range provContracts {
c.RepoPrefix = "gortex"
c.WorkspaceID = "gortex"
c.ProjectID = "gortex"
reg.Add(c)
}
for _, c := range consContracts {
c.RepoPrefix = "web"
c.WorkspaceID = "gortex"
c.ProjectID = "gortex"
reg.Add(c)
}
result := Match(reg)
var paired *CrossLink
for i, m := range result.Matched {
if m.ContractID == "http::POST::/v1/tools/{p1}" {
paired = &result.Matched[i]
break
}
}
if paired == nil {
t.Fatalf("expected POST /v1/tools/{p1} pair; matches=%d, orphan_providers=%d, orphan_consumers=%d",
len(result.Matched), len(result.OrphanProviders), len(result.OrphanConsumers))
}
if !paired.CrossRepo {
t.Errorf("expected CrossRepo=true (provider in gortex, consumer in web)")
}
if paired.Provider.RepoPrefix != "gortex" || paired.Consumer.RepoPrefix != "web" {
t.Errorf("wrong repo wiring: provider=%s consumer=%s",
paired.Provider.RepoPrefix, paired.Consumer.RepoPrefix)
}
}
// keysOf returns the keys of a map of Contracts for failure diagnostics.
func keysOf(m map[string]Contract) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
func TestHTTPExtractor_Dart_Consumers(t *testing.T) {
src := []byte(`class TuckApiClient {
final Dio _dio;
TuckApiClient(this._dio);
Future<void> createTuck(Map<String, dynamic> data) async {
await _dio.post('/v1/tucks', data: data);
}
Future<void> deleteTuck(String id) async {
await _dio.delete('/v1/tucks/$id');
}
Future<String> listHealth() async {
final r = await http.get(Uri.parse('/v1/health'));
return r.body;
}
}
`)
nodes := makeNodes("client.dart", []struct {
name string
start, end int
}{
{"createTuck", 5, 7},
{"deleteTuck", 9, 11},
{"listHealth", 13, 16},
})
ext := &HTTPExtractor{}
contracts := ext.Extract("client.dart", src, nodes, nil)
want := map[string]string{
"http::POST::/v1/tucks": "client.dart::createTuck",
"http::DELETE::/v1/tucks/{p1}": "client.dart::deleteTuck",
"http::GET::/v1/health": "client.dart::listHealth",
}
got := map[string]string{}
for _, c := range contracts {
if c.Role != RoleConsumer {
t.Errorf("expected role consumer for %s, got %s", c.ID, c.Role)
}
got[c.ID] = c.SymbolID
}
for id, sym := range want {
if got[id] != sym {
t.Errorf("missing/mismatched consumer contract:\n want %s → %s\n got %s → %s",
id, sym, id, got[id])
}
}
}
func TestHTTPExtractor_SupportedLanguages(t *testing.T) {
ext := &HTTPExtractor{}
langs := ext.SupportedLanguages()
// Spot-check the specific languages rather than the count so adding
// a new language's patterns (T2.1 added dart) doesn't force an
// unrelated test update.
want := []string{"go", "typescript", "javascript", "python", "java", "dart"}
set := make(map[string]bool, len(langs))
for _, l := range langs {
set[l] = true
}
for _, w := range want {
if !set[w] {
t.Errorf("SupportedLanguages missing %q; got %v", w, langs)
}
}
}
func TestHTTPExtractor_NoSymbol(t *testing.T) {
// When there are no enclosing functions, SymbolID should be empty.
src := []byte(`r.GET("/api/test", handler)`)
contracts := (&HTTPExtractor{}).Extract("main.go", src, nil, nil)
if len(contracts) != 1 {
t.Fatalf("expected 1 contract, got %d", len(contracts))
}
if contracts[0].SymbolID != "" {
t.Errorf("expected empty symbol ID, got %s", contracts[0].SymbolID)
}
}
// Regression: the fiber pattern's `.VERB(` anchor used to match verb names
// embedded in string literals — e.g. the prefilter marker `[]byte(".GET(")`
// — and the `[^"`+"`"+`]+` path capture would overshoot the closing quote
// and chew source code until the next quote on the next line, emitting
// contracts whose "path" was a chunk of source. Requiring the captured path
// to start with `/` rejects these false positives.
func TestHTTPExtractor_Go_Fiber_NoMatchOnVerbInsideStringLiteral(t *testing.T) {
src := []byte("package contracts\n" +
"\n" +
"var markers = [][]byte{\n" +
"\t[]byte(\".GET(\"),\n" +
"\t[]byte(\".POST(\"),\n" +
"\t[]byte(\".PUT(\"),\n" +
"\t[]byte(\".DELETE(\"),\n" +
"\t[]byte(\".PATCH(\"),\n" +
"}\n")
contracts := (&HTTPExtractor{}).Extract("markers.go", src, nil, nil)
for _, c := range contracts {
if c.Meta["framework"] == "fiber" {
t.Errorf("fiber pattern matched a verb literal inside a string: id=%q path=%q", c.ID, c.Meta["path"])
}
}
}
// TestHTTPExtractor_PathParamNames asserts that the developer-written
// parameter names are preserved on Meta["path_param_names"] alongside
// the positional Meta["path_params"]. The contract ID stays positional
// (so cross-repo matching is naming-agnostic) but the source-side
// names are surfaced for display, OpenAPI export, and drift detection.
func TestHTTPExtractor_PathParamNames(t *testing.T) {
cases := []struct {
name string
src string
filePath string
wantPath string
wantNames []string
}{
{
name: "go net/http stdlib mux",
src: `mux.HandleFunc("DELETE /v1/overlay/sessions/{id}", h)`,
filePath: "main.go",
wantPath: "/v1/overlay/sessions/{p1}",
wantNames: []string{"id"},
},
{
name: "go gin colon style multi-param",
src: `r.GET("/users/:userID/posts/:postID", h)`,
filePath: "main.go",
wantPath: "/users/{p1}/posts/{p2}",
wantNames: []string{"userID", "postID"},
},
{
name: "express colon style",
src: `app.get('/api/products/:id', h)`,
filePath: "routes.ts",
wantPath: "/api/products/{p1}",
wantNames: []string{"id"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
out := (&HTTPExtractor{}).Extract(tc.filePath, []byte(tc.src), nil, nil)
if len(out) == 0 {
t.Fatalf("no contracts extracted from %q", tc.src)
}
c := out[0]
if got, _ := c.Meta["path"].(string); got != tc.wantPath {
t.Errorf("path = %q, want %q", got, tc.wantPath)
}
gotNames, _ := c.Meta["path_param_names"].([]string)
if len(gotNames) != len(tc.wantNames) {
t.Fatalf("path_param_names = %v, want %v", gotNames, tc.wantNames)
}
for i := range tc.wantNames {
if gotNames[i] != tc.wantNames[i] {
t.Errorf("path_param_names[%d] = %q, want %q", i, gotNames[i], tc.wantNames[i])
}
}
})
}
}
// And the positive case: a real fiber registration still produces a contract.
func TestHTTPExtractor_Go_Fiber_RealRoute(t *testing.T) {
src := []byte("package main\n" +
"\n" +
"import \"github.com/gofiber/fiber/v2\"\n" +
"\n" +
"func register(app *fiber.App) {\n" +
"\tapp.GET(\"/v1/users\", listUsers)\n" +
"\tapp.DELETE(\"/v1/users/:id\", deleteUser)\n" +
"}\n")
contracts := (&HTTPExtractor{}).Extract("main.go", src, nil, nil)
byID := map[string]Contract{}
for _, c := range contracts {
byID[c.ID] = c
}
if _, ok := byID["http::GET::/v1/users"]; !ok {
t.Errorf("expected http::GET::/v1/users; got %v", byID)
}
if _, ok := byID["http::DELETE::/v1/users/{p1}"]; !ok {
t.Errorf("expected http::DELETE::/v1/users/{p1}; got %v", byID)
}
}
func TestHTTPExtractor_Swift_Vapor(t *testing.T) {
src := []byte(`
import Vapor
func routes(_ app: Application) throws {
app.get("users") { req in
return "users"
}
app.post("users", use: createUser)
routes.delete("users", ":id") { req in
return "deleted"
}
}
`)
nodes := makeNodes("routes.swift", []struct {
name string
start, end int
}{
{"routes", 4, 12},
})
ext := &HTTPExtractor{}
contracts := ext.Extract("routes.swift", src, nodes, nil)
if len(contracts) != 3 {
t.Fatalf("expected 3 Vapor routes, got %d: %+v", len(contracts), contracts)
}
methods := map[string]bool{}
for _, c := range contracts {
if c.Meta["framework"] != "vapor" {
t.Errorf("framework = %v, want vapor", c.Meta["framework"])
}
methods[c.Meta["method"].(string)] = true
}
for _, m := range []string{"GET", "POST", "DELETE"} {
if !methods[m] {
t.Errorf("expected a %s route, got methods %v", m, methods)
}
}
}
func TestHTTPExtractor_Swift_Alamofire(t *testing.T) {
src := []byte(`
import Alamofire
func fetchUsers() {
AF.request("https://api.example.com/users", method: .get)
session.request("https://api.example.com/users", method: .post)
}
`)
nodes := makeNodes("client.swift", []struct {
name string
start, end int
}{
{"fetchUsers", 4, 7},
})
ext := &HTTPExtractor{}
contracts := ext.Extract("client.swift", src, nodes, nil)
if len(contracts) != 2 {
t.Fatalf("expected 2 Alamofire consumers, got %d: %+v", len(contracts), contracts)
}
for _, c := range contracts {
if c.Meta["framework"] != "alamofire" {
t.Errorf("framework = %v, want alamofire", c.Meta["framework"])
}
if c.Role != RoleConsumer {
t.Errorf("role = %v, want consumer", c.Role)
}
}
}
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)
}
}