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
1004 lines
29 KiB
Go
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)
|
|
}
|
|
}
|