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
216 lines
8.2 KiB
Go
216 lines
8.2 KiB
Go
package contracts
|
|
|
|
import (
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
// ContractType identifies the protocol or mechanism of a contract.
|
|
type ContractType string
|
|
|
|
const (
|
|
ContractHTTP ContractType = "http"
|
|
ContractGRPC ContractType = "grpc"
|
|
// ContractThrift covers Apache Thrift IDL services. Provider
|
|
// contracts come from `service { ... }` blocks in .thrift files;
|
|
// the consumer side is usually detected through the generated-stub
|
|
// patterns the gRPC extractor recognises (New<Service>Client), so
|
|
// the matcher's canonical-name join treats grpc and thrift as one
|
|
// RPC family when pairing.
|
|
ContractThrift ContractType = "thrift"
|
|
ContractGraphQL ContractType = "graphql"
|
|
ContractTopic ContractType = "topic"
|
|
ContractWS ContractType = "ws"
|
|
ContractEnv ContractType = "env"
|
|
ContractOpenAPI ContractType = "openapi"
|
|
ContractDependency ContractType = "dependency"
|
|
// ContractDI covers NestJS-style dependency-injection bindings
|
|
// derived from the EdgeProvides / EdgeConsumes edges the
|
|
// TypeScript extractor emits for @Module providers and @Inject
|
|
// consumers. A matched pair has the same `di::<token>` ID on
|
|
// both sides so orphan detection works via the standard matcher.
|
|
ContractDI ContractType = "di"
|
|
// ContractTRPC covers tRPC routers and client procedure calls. The
|
|
// provider side is the procedures of a createTRPCRouter / t.router
|
|
// object; the consumer side is the typed proxy chain
|
|
// (trpc.<router>.<procedure>.useQuery()). Both sides share the
|
|
// canonical ID `trpc::<router>.<procedure>` so the matcher pairs them.
|
|
ContractTRPC ContractType = "trpc"
|
|
)
|
|
|
|
// Role indicates whether a symbol provides or consumes a contract.
|
|
type Role string
|
|
|
|
const (
|
|
RoleProvider Role = "provider"
|
|
RoleConsumer Role = "consumer"
|
|
)
|
|
|
|
// Contract represents a detected API contract (e.g., an HTTP route) attached
|
|
// to a symbol in the graph.
|
|
type Contract struct {
|
|
ID string `json:"id"`
|
|
Type ContractType `json:"type"`
|
|
Role Role `json:"role"`
|
|
SymbolID string `json:"symbol_id"`
|
|
FilePath string `json:"file_path"`
|
|
Line int `json:"line"`
|
|
RepoPrefix string `json:"repo_prefix,omitempty"`
|
|
// WorkspaceID is the hard-boundary slug. The matcher pairs
|
|
// providers and consumers only inside the same (workspace,
|
|
// project) tuple — across-workspace contracts never pair. Empty
|
|
// falls back to RepoPrefix (the default).
|
|
WorkspaceID string `json:"workspace_id,omitempty"`
|
|
// ProjectID is the soft sub-boundary inside a workspace. Across-
|
|
// project (same-workspace) contracts become orphans rather than
|
|
// paired matches. Empty falls back to RepoPrefix.
|
|
ProjectID string `json:"project_id,omitempty"`
|
|
Meta map[string]any `json:"meta,omitempty"`
|
|
Confidence float64 `json:"confidence"`
|
|
}
|
|
|
|
// EffectiveWorkspace returns the workspace slug that participates in
|
|
// the matcher's boundary check. WorkspaceID wins when set; otherwise
|
|
// the default is RepoPrefix (one workspace per repo). Callers
|
|
// shouldn't reason about empty strings — always go through this
|
|
// helper so the default rule lives in one place.
|
|
func (c Contract) EffectiveWorkspace() string {
|
|
if c.WorkspaceID != "" {
|
|
return c.WorkspaceID
|
|
}
|
|
return c.RepoPrefix
|
|
}
|
|
|
|
// EffectiveProject returns the project slug. Same default rule as
|
|
// EffectiveWorkspace: ProjectID wins, otherwise RepoPrefix.
|
|
func (c Contract) EffectiveProject() string {
|
|
if c.ProjectID != "" {
|
|
return c.ProjectID
|
|
}
|
|
return c.RepoPrefix
|
|
}
|
|
|
|
// paramPatterns matches common path parameter styles and normalises them to {param}.
|
|
var paramPatterns = regexp.MustCompile(`:(\w+)|<(\w+(?::\w+)?)>|\{(\w+)\}`)
|
|
|
|
// tplBasePrefix matches a leading JS/TS template-literal placeholder
|
|
// optionally preceded by "/" — e.g. ${API_URL}, /${TUCK_API_URL},
|
|
// ${process.env.HOST} — that a consumer glues onto the front of an
|
|
// endpoint path to produce the full URL. Stripping it lets consumer
|
|
// contracts match providers' canonical "/v1/..." paths.
|
|
var tplBasePrefix = regexp.MustCompile(`^/?\$\{[^}]+\}`)
|
|
|
|
// tplInlineParam matches any remaining inline placeholders in path
|
|
// segments — both ${name} (JS/TS, and Dart's braced form) and $name
|
|
// (Dart's bare form, e.g. /v1/tucks/$id). Both collapse to {name}
|
|
// so consumer paths align with provider route declarations.
|
|
var tplInlineParam = regexp.MustCompile(`\$\{([^}]+)\}|\$([a-zA-Z_][a-zA-Z0-9_]*)`)
|
|
|
|
// NormalizeHTTPPath converts path parameters from various frameworks into the
|
|
// canonical {param} form. Examples:
|
|
//
|
|
// /users/:id -> /users/{id}
|
|
// /users/<int:id> -> /users/{id}
|
|
// /users/{id} -> /users/{id} (no change)
|
|
// ${API_URL}/users -> /users
|
|
// /${TUCK_API_URL}/users -> /users
|
|
// /users/${id} -> /users/{id}
|
|
func NormalizeHTTPPath(path string) string {
|
|
out, _ := NormalizeHTTPPathWithParams(path)
|
|
return out
|
|
}
|
|
|
|
// NormalizeHTTPPathWithParams is NormalizeHTTPPath plus the *original*
|
|
// parameter names in declaration order — one entry per "{p1}", "{p2}", …
|
|
// in the returned canonical path. The canonical path stays positional
|
|
// so cross-repo Contract.ID matching is naming-agnostic; callers
|
|
// preserve the source-side names separately (Meta["path_param_names"])
|
|
// for display, drift detection, and OpenAPI export.
|
|
//
|
|
// Example:
|
|
//
|
|
// /v1/workspaces/{wid}/tags/:id -> ("/v1/workspaces/{p1}/tags/{p2}", []string{"wid", "id"})
|
|
func NormalizeHTTPPathWithParams(path string) (string, []string) {
|
|
// Strip leading/trailing whitespace and quotes.
|
|
path = strings.Trim(path, " \t\"'`")
|
|
|
|
// Strip scheme + authority so a consumer URL like
|
|
// "http://api.example.com/v1/users" matches a provider route like
|
|
// "/v1/users". Without this, the consumer's Contract.ID includes the
|
|
// host and never pairs with the provider's, so cross-service traversal
|
|
// stops at the HTTP call site.
|
|
if idx := strings.Index(path, "://"); idx >= 0 {
|
|
rest := path[idx+3:]
|
|
if slash := strings.Index(rest, "/"); slash >= 0 {
|
|
path = rest[slash:]
|
|
} else {
|
|
path = "/"
|
|
}
|
|
}
|
|
|
|
// Strip a leading template-literal placeholder (with optional leading
|
|
// slash) — the base-URL slot that a consumer interpolates. After this
|
|
// the path is the same shape as the provider's route declaration.
|
|
path = tplBasePrefix.ReplaceAllString(path, "")
|
|
|
|
// Any remaining inline placeholders are path parameters. Both ${name}
|
|
// (group 1) and Dart-style $name (group 2) collapse to {name} so the
|
|
// canonical param normaliser below treats them uniformly.
|
|
path = tplInlineParam.ReplaceAllStringFunc(path, func(m string) string {
|
|
sub := tplInlineParam.FindStringSubmatch(m)
|
|
name := sub[1]
|
|
if name == "" {
|
|
name = sub[2]
|
|
}
|
|
return "{" + name + "}"
|
|
})
|
|
|
|
// Normalise parameter placeholders to positional names — {p1}, {p2}, …
|
|
// HTTP routing identity is positional, not name-based: a provider
|
|
// declaring `DELETE /v1/workspaces/{wid}/tags/{id}` and a consumer
|
|
// calling `DELETE /v1/workspaces/{workspaceId}/tags/{id}` describe
|
|
// the same route, and must hash to the same Contract.ID for
|
|
// cross-repo matching (`contracts check` / `validate`) to work.
|
|
// Keeping the user-written name in the ID is a common source of
|
|
// false orphans across services whose provider and consumer teams
|
|
// chose different names for the same slot. We capture the original
|
|
// name on the way through so callers that want to show the
|
|
// developer-written identifier (dashboard, OpenAPI) can.
|
|
var paramCounter int
|
|
var origNames []string
|
|
path = paramPatterns.ReplaceAllStringFunc(path, func(m string) string {
|
|
paramCounter++
|
|
// paramPatterns has three alternatives — :name, <int:name>, {name}
|
|
// — each in its own capture group. Pick the first non-empty one.
|
|
sub := paramPatterns.FindStringSubmatch(m)
|
|
var name string
|
|
for i := 1; i < len(sub); i++ {
|
|
if sub[i] != "" {
|
|
name = sub[i]
|
|
break
|
|
}
|
|
}
|
|
// Strip a leading "type:" qualifier from the Django/Flask style
|
|
// (<int:id>, <slug:title>) — we want the bare name, not the
|
|
// converter prefix.
|
|
if idx := strings.Index(name, ":"); idx >= 0 {
|
|
name = name[idx+1:]
|
|
}
|
|
origNames = append(origNames, name)
|
|
return fmt.Sprintf("{p%d}", paramCounter)
|
|
})
|
|
|
|
// Ensure leading slash.
|
|
if !strings.HasPrefix(path, "/") {
|
|
path = "/" + path
|
|
}
|
|
|
|
// Remove trailing slash (except for root).
|
|
if len(path) > 1 {
|
|
path = strings.TrimRight(path, "/")
|
|
}
|
|
|
|
return path, origNames
|
|
}
|