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

295 lines
8.7 KiB
Go

package graph_test
// Node-id stability parity test.
//
// Overlay and cloud paths assume node IDs produced by two different
// indexer invocations of the same source commit are byte-identical.
// If that ever drifts (host-local state in the ID, parse-order leaks,
// RNG, time, etc.), overlay merging silently breaks: the daemon's
// overlay node IDs no longer match the server's base node IDs, edges
// land on dangling endpoints, and queries return half-true answers.
//
// This test runs the live indexer pipeline twice on a freshly-copied
// pair of identical source trees. Different absolute paths simulate
// "two checkouts on one machine" (the cheap proxy for "two machines"
// — the only difference between the two cases is the absolute parent
// directory which the indexer is supposed to strip via repo-prefixing).
import (
"context"
"os"
"path/filepath"
"sort"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"github.com/zzet/gortex/internal/config"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/indexer"
"github.com/zzet/gortex/internal/parser"
"github.com/zzet/gortex/internal/parser/languages"
"github.com/zzet/gortex/internal/search"
)
func newParityRegistry() *parser.Registry {
r := parser.NewRegistry()
r.Register(languages.NewGoExtractor())
return r
}
// fixtureFiles is the source tree planted under each "checkout".
// Mix of:
// - top-level package (Go) — simple types and methods
// - sub-package — exercises path-based ID composition
// - HTTP route via stdlib mux — exercises contract emission
// - import that crosses sub-packages — exercises resolver
//
// Multiple files per package and multiple symbols per file expose any
// parse-order or map-iteration-order leakage in node IDs.
var fixtureFiles = map[string]string{
"go.mod": "module example.com/parity\n\ngo 1.21\n",
"main.go": `package main
import (
"net/http"
"example.com/parity/internal/auth"
)
type Server struct{}
func (s *Server) Start() error {
mux := http.NewServeMux()
mux.HandleFunc("/api/auth/login", auth.LoginHandler)
mux.HandleFunc("/api/health", s.Health)
return http.ListenAndServe(":8080", mux)
}
func (s *Server) Health(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("ok"))
}
func main() {
srv := &Server{}
_ = srv.Start()
}
`,
"helpers.go": `package main
import "strings"
func normalize(s string) string { return strings.ToLower(s) }
func reverse(s string) string {
r := []rune(s)
for i, j := 0, len(r)-1; i < j; i, j = i+1, j-1 {
r[i], r[j] = r[j], r[i]
}
return string(r)
}
`,
"internal/auth/login.go": `package auth
import "net/http"
type Credentials struct {
User string
Pass string
}
func LoginHandler(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("login"))
}
func Validate(c Credentials) bool {
return c.User != "" && c.Pass != ""
}
`,
"internal/auth/token.go": `package auth
import "time"
type Token struct {
Value string
ExpiresAt time.Time
}
func NewToken(value string) Token {
return Token{Value: value, ExpiresAt: time.Now().Add(time.Hour)}
}
`,
}
// TestNodeIDStability_Parity is the iteration-1 gate test for node-ID
// determinism. Indexes the same source tree from two distinct absolute
// paths (different temp dirs) and asserts the produced node IDs are
// identical sets after stripping the repo prefix. This catches:
//
// - host-local state (working directory, hostname) leaking into IDs
// - parse-order non-determinism (goroutine scheduling) leaking into IDs
// - map-iteration-order leaking into IDs
//
// The repo prefix is allowed to differ because it's deliberately a
// human-readable disambiguator; the rest of the ID after the prefix
// must match byte-for-byte.
//
// For monorepo-shaped IDs the comparison is "same set" — we don't
// require parse-order to be stable, only the final ID set.
func TestNodeIDStability_Parity(t *testing.T) {
idsA := indexFixture(t, "checkout-alpha")
idsB := indexFixture(t, "checkout-beta")
// Strip repo prefix so we're comparing what's structural about the
// ID and not the deliberately-different prefix.
stripped := func(in []string, prefix string) []string {
out := make([]string, 0, len(in))
pre := prefix + "/"
for _, id := range in {
if len(id) > len(pre) && id[:len(pre)] == pre {
out = append(out, id[len(pre):])
continue
}
out = append(out, id)
}
sort.Strings(out)
return out
}
got := stripped(idsA.NodeIDs, idsA.Prefix)
want := stripped(idsB.NodeIDs, idsB.Prefix)
if !assert.Equal(t, want, got, "node IDs must be byte-identical across two indexings of the same source tree (after stripping repo prefix). divergence breaks overlay merging across daemon and cloud.") {
// Surface the first few divergences directly so the failure
// message points at the offending IDs rather than the full
// list-of-thousands diff.
diff := symmetricDifference(got, want)
if len(diff) > 0 {
t.Logf("symmetric difference (up to 20 ids): %v", diff[:minInt(len(diff), 20)])
}
}
}
type fixtureResult struct {
NodeIDs []string
Prefix string
}
// indexFixture writes the fixture into a fresh temp dir under the
// given checkout name, indexes it via MultiIndexer (the warmup path),
// and returns the full set of node IDs in the resulting graph plus
// the repo prefix MultiIndexer assigned.
//
// We use MultiIndexer with two configured repos (the fixture + a
// throwaway sibling) so that willBeMultiRepo is true and the prefix
// path is exercised — that's the production code path the daemon
// runs and the one overlay/cloud merging will rely on.
func indexFixture(t *testing.T, checkoutName string) fixtureResult {
t.Helper()
// Plant the fixture in a unique temp tree.
root := filepath.Join(t.TempDir(), checkoutName)
require.NoError(t, os.MkdirAll(root, 0o755))
for relPath, content := range fixtureFiles {
full := filepath.Join(root, relPath)
require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o755))
require.NoError(t, os.WriteFile(full, []byte(content), 0o644))
}
// Companion repo so multi-repo prefixing kicks in. We keep it
// minimal — no parity comparison runs on it; it only exists to
// flip willBeMultiRepo.
companion := filepath.Join(t.TempDir(), checkoutName+"-companion")
require.NoError(t, os.MkdirAll(companion, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(companion, "noop.go"),
[]byte("package companion\n\nfunc Noop() {}\n"), 0o644))
tmpCfg := filepath.Join(t.TempDir(), "config-"+checkoutName+".yaml")
gc := &config.GlobalConfig{
Repos: []config.RepoEntry{
{Path: root, Name: checkoutName},
{Path: companion, Name: checkoutName + "-companion"},
},
}
gc.SetConfigPath(tmpCfg)
require.NoError(t, gc.Save())
cm, err := config.NewConfigManager(tmpCfg)
require.NoError(t, err)
g := graph.New()
mi := indexer.NewMultiIndexer(g, newParityRegistry(), search.NewBM25(), cm, zap.NewNop())
for _, entry := range cm.Global().Repos {
_, err := mi.TrackRepoCtx(context.Background(), entry)
require.NoError(t, err, "track %s", entry.Name)
}
ids := []string{}
prefix := checkoutName
for _, n := range g.AllNodes() {
// This test is about source-symbol IDs (functions, methods,
// types, files) — the things overlay merging keys on.
// Contract / Module / Builtin nodes are deliberately
// cross-repo singletons (one `dep::foo`, `module::pypi:requests`,
// `builtin::go::len` shared across every repo that uses them)
// and don't carry RepoPrefix; skip them so the parity gate
// stays precise about what it gates. KindFunction nodes
// with meta.external=true are the per-symbol stubs the
// external-call attribution materialises for stdlib/dep
// targets — same rule.
if n.Kind == graph.KindContract || n.Kind == graph.KindModule || n.Kind == graph.KindBuiltin {
continue
}
if ext, _ := n.Meta["external"].(bool); ext {
continue
}
if n.RepoPrefix == "" {
t.Fatalf("node %q has empty RepoPrefix in multi-repo mode", n.ID)
}
if n.RepoPrefix != checkoutName {
continue
}
ids = append(ids, n.ID)
}
require.NotEmpty(t, ids, "no fixture nodes produced — fixture or indexer regression")
return fixtureResult{NodeIDs: ids, Prefix: prefix}
}
// symmetricDifference returns elements present in exactly one of a, b.
// Both must be sorted.
func symmetricDifference(a, b []string) []string {
var diff []string
i, j := 0, 0
for i < len(a) && j < len(b) {
switch {
case a[i] == b[j]:
i++
j++
case a[i] < b[j]:
diff = append(diff, "only-in-A:"+a[i])
i++
default:
diff = append(diff, "only-in-B:"+b[j])
j++
}
}
for ; i < len(a); i++ {
diff = append(diff, "only-in-A:"+a[i])
}
for ; j < len(b); j++ {
diff = append(diff, "only-in-B:"+b[j])
}
return diff
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}