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
354 lines
12 KiB
Go
354 lines
12 KiB
Go
package indexer
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/zzet/gortex/internal/config"
|
|
"github.com/zzet/gortex/internal/contracts"
|
|
"github.com/zzet/gortex/internal/graph"
|
|
"github.com/zzet/gortex/internal/parser"
|
|
"github.com/zzet/gortex/internal/parser/languages"
|
|
)
|
|
|
|
// TestContracts_ResponseFromMakeSlice reproduces the user's
|
|
// `result := make([]toolInfo, 0, len(tools))` case: a top-level
|
|
// response value bound from a builtin (make), not a method call.
|
|
// traceVarTypeFromBody can't resolve `make` (no graph node for the
|
|
// builtin), so we fall back to bindLiteralTypeFromBody, which knows
|
|
// `make([]T, …)` produces T-slice. After this pass the contract
|
|
// carries response_type="<repo>/handler.go::toolInfo",
|
|
// response_repeated=true, and response_shape populated with
|
|
// toolInfo's fields — the dashboard renders the response as
|
|
// `[{name: string, description: string}]`.
|
|
func TestContracts_ResponseFromMakeSlice(t *testing.T) {
|
|
dir := t.TempDir()
|
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "handler.go"), []byte(`package main
|
|
|
|
import "net/http"
|
|
|
|
type toolInfo struct {
|
|
Name string ` + "`json:\"name\"`" + `
|
|
Description string ` + "`json:\"description\"`" + `
|
|
}
|
|
|
|
type Handler struct{ tools map[string]any }
|
|
|
|
func (h *Handler) Register(mux *http.ServeMux) {
|
|
mux.HandleFunc("GET /v1/tools", h.handleListTools)
|
|
}
|
|
|
|
func (h *Handler) handleListTools(w http.ResponseWriter, _ *http.Request) {
|
|
result := make([]toolInfo, 0, len(h.tools))
|
|
WriteJSON(w, http.StatusOK, result)
|
|
}
|
|
|
|
func WriteJSON(w http.ResponseWriter, code int, body any) {}
|
|
`), 0o644))
|
|
|
|
g := graph.New()
|
|
reg := parser.NewRegistry()
|
|
languages.RegisterAll(reg)
|
|
cfg := config.Default()
|
|
idx := New(g, reg, cfg.Index, zap.NewNop())
|
|
_, err := idx.Index(dir)
|
|
require.NoError(t, err)
|
|
|
|
cr := idx.ContractRegistry()
|
|
require.NotNil(t, cr)
|
|
|
|
var found contracts.Contract
|
|
for _, c := range cr.All() {
|
|
if c.Type == contracts.ContractHTTP && strings.Contains(c.ID, "/v1/tools") {
|
|
found = c
|
|
break
|
|
}
|
|
}
|
|
require.NotEmpty(t, found.ID, "expected HTTP contract for /v1/tools")
|
|
|
|
rt, _ := found.Meta["response_type"].(string)
|
|
if !strings.Contains(rt, "toolInfo") {
|
|
t.Errorf("response_type = %q, want a toolInfo reference (resolved via make-slice fallback)", rt)
|
|
}
|
|
if r, _ := found.Meta["response_repeated"].(bool); !r {
|
|
t.Errorf("response_repeated = false, want true (declared as []toolInfo)")
|
|
}
|
|
if got, _ := found.Meta["schema_source"].(string); got != "extracted" {
|
|
t.Errorf("schema_source = %q, want \"extracted\"", got)
|
|
}
|
|
|
|
// And response_shape must be inlined from the toolInfo type node.
|
|
shape := found.Meta["response_shape"]
|
|
if shape == nil {
|
|
t.Fatalf("response_shape missing; want toolInfo's struct fields inlined")
|
|
}
|
|
var fields []contracts.ShapeField
|
|
switch s := shape.(type) {
|
|
case *contracts.Shape:
|
|
fields = s.Fields
|
|
case contracts.Shape:
|
|
fields = s.Fields
|
|
}
|
|
if len(fields) != 2 {
|
|
t.Errorf("response_shape has %d fields, want 2 (Name, Description)", len(fields))
|
|
}
|
|
|
|
// The ugly response_expr ("WriteJSON(...)") must be gone — once
|
|
// response_type resolves we delete the expr placeholder.
|
|
if got, _ := found.Meta["response_expr"].(string); got != "" {
|
|
t.Errorf("response_expr should be empty when response_type resolves; got %q", got)
|
|
}
|
|
}
|
|
|
|
// TestContracts_HandlerHintsStripped asserts that the internal
|
|
// handler_ident / handler_trail extractor scratchpad never makes it
|
|
// onto the final wire shape, regardless of whether cross-file handler
|
|
// resolution succeeded. A user looking at the dashboard's location
|
|
// meta should never see fragments like `handler_trail: "/users", listUsers"`
|
|
// — those are extractor-internal hints, not contract data.
|
|
func TestContracts_HandlerHintsStripped(t *testing.T) {
|
|
dir := t.TempDir()
|
|
// Route registration referencing a handler that's NOT defined in
|
|
// this repo — the cross-file resolver will give up, and previously
|
|
// the unresolved hints would leak through to the wire. With the
|
|
// unconditional cleanup the hints are gone either way.
|
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "main.go"), []byte(`package main
|
|
|
|
import "net/http"
|
|
|
|
func register(mux *http.ServeMux) {
|
|
mux.HandleFunc("GET /v1/external", externalHandler)
|
|
}
|
|
`), 0o644))
|
|
|
|
g := graph.New()
|
|
reg := parser.NewRegistry()
|
|
languages.RegisterAll(reg)
|
|
cfg := config.Default()
|
|
idx := New(g, reg, cfg.Index, zap.NewNop())
|
|
_, err := idx.Index(dir)
|
|
require.NoError(t, err)
|
|
|
|
cr := idx.ContractRegistry()
|
|
require.NotNil(t, cr)
|
|
|
|
for _, c := range cr.All() {
|
|
if c.Type != contracts.ContractHTTP {
|
|
continue
|
|
}
|
|
if _, leaked := c.Meta["handler_ident"]; leaked {
|
|
t.Errorf("contract %s leaks handler_ident: %v", c.ID, c.Meta["handler_ident"])
|
|
}
|
|
if _, leaked := c.Meta["handler_trail"]; leaked {
|
|
t.Errorf("contract %s leaks handler_trail: %v", c.ID, c.Meta["handler_trail"])
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestContracts_EnvelopeBuiltinBindings reproduces the user-reported
|
|
// "data doesn't match" case: an envelope whose values come from
|
|
// non-traceable bindings (`r.PathValue("ws")`, `make([]string, ...)`).
|
|
// Both rows must end up typed via the literal-binding fallback so the
|
|
// dashboard renders `workspace: string` and `repos: [string]` instead
|
|
// of empty fields.
|
|
func TestContracts_EnvelopeBuiltinBindings(t *testing.T) {
|
|
dir := t.TempDir()
|
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "handler.go"), []byte(`package main
|
|
|
|
import "net/http"
|
|
|
|
type Handler struct{}
|
|
|
|
func (h *Handler) Register(mux *http.ServeMux) {
|
|
mux.HandleFunc("GET /v1/workspaces/{ws}/repos", h.handleWorkspaceRoster)
|
|
}
|
|
|
|
func (h *Handler) handleWorkspaceRoster(w http.ResponseWriter, r *http.Request) {
|
|
ws := r.PathValue("ws")
|
|
repos := make([]string, 0, 4)
|
|
WriteJSON(w, http.StatusOK, map[string]any{"workspace": ws, "repos": repos})
|
|
}
|
|
|
|
func WriteJSON(w http.ResponseWriter, code int, body any) {}
|
|
`), 0o644))
|
|
|
|
g := graph.New()
|
|
reg := parser.NewRegistry()
|
|
languages.RegisterAll(reg)
|
|
cfg := config.Default()
|
|
idx := New(g, reg, cfg.Index, zap.NewNop())
|
|
_, err := idx.Index(dir)
|
|
require.NoError(t, err)
|
|
|
|
cr := idx.ContractRegistry()
|
|
require.NotNil(t, cr)
|
|
|
|
var found contracts.Contract
|
|
var ok bool
|
|
for _, c := range cr.All() {
|
|
if c.Type == contracts.ContractHTTP && strings.Contains(c.ID, "/v1/workspaces") {
|
|
found, ok = c, true
|
|
break
|
|
}
|
|
}
|
|
require.True(t, ok)
|
|
|
|
envRaw, ok := found.Meta["response_envelope"].([]map[string]any)
|
|
require.True(t, ok)
|
|
require.Len(t, envRaw, 2)
|
|
byName := map[string]map[string]any{}
|
|
for _, row := range envRaw {
|
|
if name, _ := row["name"].(string); name != "" {
|
|
byName[name] = row
|
|
}
|
|
}
|
|
|
|
if got, _ := byName["workspace"]["type"].(string); got != "string" {
|
|
t.Errorf("envelope[workspace].type = %q, want \"string\" (from r.PathValue)", got)
|
|
}
|
|
if got, _ := byName["repos"]["type"].(string); got != "string" {
|
|
t.Errorf("envelope[repos].type = %q, want \"string\" (slice element from make([]string, ...))", got)
|
|
}
|
|
if r, _ := byName["repos"]["repeated"].(bool); !r {
|
|
t.Errorf("envelope[repos].repeated = false, want true (declared as []string)")
|
|
}
|
|
if r, _ := byName["workspace"]["repeated"].(bool); r {
|
|
t.Errorf("envelope[workspace].repeated = true, want false (scalar string)")
|
|
}
|
|
}
|
|
|
|
// TestContracts_ResolveEnvelopeFieldTypes asserts that the indexer's
|
|
// graph-aware post-pass walks every response_envelope row whose type
|
|
// is empty and traces its expression back to the bound method's
|
|
// return type. The dashboard then renders `repos: []Repo` instead of
|
|
// the bare `repos` identifier — the user-facing motivation is "what
|
|
// does `repos` mean in `map[string]any{"repos": repos}`?".
|
|
func TestContracts_ResolveEnvelopeFieldTypes(t *testing.T) {
|
|
dir := t.TempDir()
|
|
|
|
// Plain top-level functions so the receiver heuristic in
|
|
// traceVarTypeFromBody isn't on the critical path here — we want
|
|
// to exercise *envelope iteration + per-field resolution*, not
|
|
// the existing field-name/receiver-type matching policy. (Method
|
|
// receivers are exercised by the broader return-type tests; this
|
|
// test's contract is "envelope rows that traceVarTypeFromBody
|
|
// can resolve do get patched in place".)
|
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "service.go"), []byte(`package main
|
|
|
|
type Repo struct{ Name string }
|
|
type Workspace struct{ ID string }
|
|
|
|
func loadWorkspace(id string) (*Workspace, error) { return nil, nil }
|
|
func loadRepos(id string) ([]Repo, error) { return nil, nil }
|
|
`), 0o644))
|
|
|
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "handler.go"), []byte(`package main
|
|
|
|
import "net/http"
|
|
|
|
type Handler struct{}
|
|
|
|
func (h *Handler) Register(mux *http.ServeMux) {
|
|
mux.HandleFunc("GET /v1/workspaces/{id}/repos", h.handleWorkspaceRoster)
|
|
}
|
|
|
|
func (h *Handler) handleWorkspaceRoster(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
ws, _ := loadWorkspace(id)
|
|
repos, _ := loadRepos(id)
|
|
WriteJSON(w, http.StatusOK, map[string]any{"workspace": ws, "repos": repos})
|
|
}
|
|
|
|
func WriteJSON(w http.ResponseWriter, code int, body any) {}
|
|
`), 0o644))
|
|
|
|
g := graph.New()
|
|
reg := parser.NewRegistry()
|
|
languages.RegisterAll(reg)
|
|
cfg := config.Default()
|
|
idx := New(g, reg, cfg.Index, zap.NewNop())
|
|
_, err := idx.Index(dir)
|
|
require.NoError(t, err)
|
|
|
|
cr := idx.ContractRegistry()
|
|
require.NotNil(t, cr)
|
|
|
|
var found contracts.Contract
|
|
var ok bool
|
|
for _, c := range cr.All() {
|
|
if c.Type == contracts.ContractHTTP && strings.Contains(c.ID, "/v1/workspaces") && strings.Contains(c.ID, "/repos") {
|
|
found, ok = c, true
|
|
break
|
|
}
|
|
}
|
|
require.True(t, ok, "expected HTTP contract for /v1/workspaces/{p1}/repos")
|
|
|
|
envRaw, ok := found.Meta["response_envelope"].([]map[string]any)
|
|
require.True(t, ok, "response_envelope missing or wrong shape: %#v", found.Meta["response_envelope"])
|
|
require.Len(t, envRaw, 2, "envelope must carry both fields")
|
|
|
|
byName := map[string]map[string]any{}
|
|
for _, row := range envRaw {
|
|
if name, _ := row["name"].(string); name != "" {
|
|
byName[name] = row
|
|
}
|
|
}
|
|
|
|
wsType, _ := byName["workspace"]["type"].(string)
|
|
if !strings.Contains(wsType, "Workspace") {
|
|
t.Errorf("envelope[workspace].type = %q, want a Workspace ref", wsType)
|
|
}
|
|
reposType, _ := byName["repos"]["type"].(string)
|
|
if !strings.Contains(reposType, "Repo") {
|
|
t.Errorf("envelope[repos].type = %q, want a Repo ref", reposType)
|
|
}
|
|
|
|
// With every field typed, the schema is fully extracted — not
|
|
// just partial. The dashboard shows the green badge.
|
|
if got, _ := found.Meta["schema_source"].(string); got != "extracted" {
|
|
t.Errorf("schema_source = %q, want \"extracted\" once every envelope row is typed", got)
|
|
}
|
|
|
|
// And the ugly `map[string]any{...}` literal must NOT be on
|
|
// response_expr — the envelope IS the JSON shape.
|
|
if got, _ := found.Meta["response_expr"].(string); got != "" {
|
|
t.Errorf("response_expr should be empty when an envelope is present; got %q", got)
|
|
}
|
|
|
|
// Each envelope row should carry the inlined shape of its type so
|
|
// the dashboard can render the actual JSON object structure (field
|
|
// names, JSON tags, types) without doing a second graph lookup.
|
|
// Workspace has one field (ID); Repo has one field (Name) — the
|
|
// shape extractor preserves both.
|
|
for _, key := range []string{"workspace", "repos"} {
|
|
row, ok := byName[key]
|
|
if !ok {
|
|
continue
|
|
}
|
|
shape := row["shape"]
|
|
if shape == nil {
|
|
t.Errorf("envelope[%s] missing shape; want struct fields inlined", key)
|
|
continue
|
|
}
|
|
// Shape is the contracts.Shape value; check it via reflection-
|
|
// free type-assert chain. Either a *contracts.Shape (direct
|
|
// pointer) or a struct value works — Meta is map[string]any
|
|
// so we accept whatever the snapshot pass put there.
|
|
var fields []contracts.ShapeField
|
|
switch s := shape.(type) {
|
|
case *contracts.Shape:
|
|
fields = s.Fields
|
|
case contracts.Shape:
|
|
fields = s.Fields
|
|
}
|
|
if len(fields) == 0 {
|
|
t.Errorf("envelope[%s].shape has no fields; got %#v", key, shape)
|
|
}
|
|
}
|
|
}
|