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
700 lines
27 KiB
Go
700 lines
27 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/gob"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/zzet/gortex/internal/contracts"
|
|
"github.com/zzet/gortex/internal/graph"
|
|
"github.com/zzet/gortex/internal/search"
|
|
)
|
|
|
|
// TestSnapshotRoundTrip proves that save + load preserves nodes and
|
|
// edges bit-for-bit. This is the guarantee the daemon's startup restore
|
|
// depends on; a silent corruption here would give warm-started daemons
|
|
// a stale graph that doesn't match any real source file.
|
|
func TestSnapshotRoundTrip(t *testing.T) {
|
|
dir := t.TempDir()
|
|
t.Setenv("GORTEX_DAEMON_SNAPSHOT", filepath.Join(dir, "snap.gob.gz"))
|
|
|
|
orig := graph.New()
|
|
orig.AddNode(&graph.Node{ID: "a.go::Foo", Name: "Foo", Kind: graph.KindFunction, FilePath: "a.go"})
|
|
orig.AddNode(&graph.Node{ID: "b.go::Bar", Name: "Bar", Kind: graph.KindMethod, FilePath: "b.go"})
|
|
orig.AddEdge(&graph.Edge{From: "b.go::Bar", To: "a.go::Foo", Kind: graph.EdgeCalls, FilePath: "b.go", Line: 12})
|
|
|
|
saveSnapshot(orig, nil, nil, snapshotVector{}, version, zap.NewNop())
|
|
|
|
restored := graph.New()
|
|
result, err := loadSnapshot(restored, zap.NewNop())
|
|
require.NoError(t, err)
|
|
require.True(t, result.Loaded, "loadSnapshot must succeed for a freshly-written file")
|
|
|
|
assert.Equal(t, orig.NodeCount(), restored.NodeCount(),
|
|
"node count must round-trip")
|
|
assert.Equal(t, orig.EdgeCount(), restored.EdgeCount(),
|
|
"edge count must round-trip")
|
|
|
|
n := restored.GetNode("a.go::Foo")
|
|
require.NotNil(t, n)
|
|
assert.Equal(t, "Foo", n.Name)
|
|
}
|
|
|
|
// buildTestVectorIndex builds a small HNSW vector index and returns it
|
|
// serialized as a snapshotVector — the shape the daemon snapshot path
|
|
// persists.
|
|
func buildTestVectorIndex(t *testing.T) snapshotVector {
|
|
t.Helper()
|
|
const dims = 4
|
|
vec := search.NewVector(dims)
|
|
vec.Add("a.go::Foo", []float32{1, 0, 0, 0})
|
|
vec.Add("b.go::Bar", []float32{0, 1, 0, 0})
|
|
vec.Add("c.go::Baz", []float32{0, 0, 1, 0})
|
|
|
|
var buf bytes.Buffer
|
|
require.NoError(t, vec.Save(&buf))
|
|
return snapshotVector{Index: buf.Bytes(), Dims: dims, Count: vec.Count()}
|
|
}
|
|
|
|
// TestSnapshotRoundTrip_VectorIndex proves the schema-v3 vector-index
|
|
// fields survive a save + load cycle. Without this the daemon would
|
|
// re-embed the whole graph on every restart.
|
|
func TestSnapshotRoundTrip_VectorIndex(t *testing.T) {
|
|
dir := t.TempDir()
|
|
t.Setenv("GORTEX_DAEMON_SNAPSHOT", filepath.Join(dir, "snap.gob.gz"))
|
|
|
|
orig := graph.New()
|
|
orig.AddNode(&graph.Node{ID: "a.go::Foo", Name: "Foo", Kind: graph.KindFunction, FilePath: "a.go"})
|
|
|
|
want := buildTestVectorIndex(t)
|
|
saveSnapshot(orig, nil, nil, want, version, zap.NewNop())
|
|
|
|
restored := graph.New()
|
|
result, err := loadSnapshot(restored, zap.NewNop())
|
|
require.NoError(t, err)
|
|
require.True(t, result.Loaded)
|
|
|
|
got := result.Vector
|
|
assert.Equal(t, want.Dims, got.Dims, "vector dims must round-trip")
|
|
assert.Equal(t, want.Count, got.Count, "vector count must round-trip")
|
|
require.Equal(t, want.Index, got.Index, "serialized vector index bytes must round-trip")
|
|
|
|
// The restored bytes must rehydrate into a usable HNSW index.
|
|
rebuilt := search.NewVector(got.Dims)
|
|
require.NoError(t, rebuilt.LoadFrom(bytes.NewReader(got.Index)))
|
|
rebuilt.SetCount(got.Count)
|
|
hits := rebuilt.Search([]float32{1, 0, 0, 0}, 1)
|
|
require.Len(t, hits, 1)
|
|
assert.Equal(t, "a.go::Foo", hits[0], "restored index must answer nearest-neighbour queries")
|
|
}
|
|
|
|
// TestMigrateSnapshotV2toV3 proves the v2→v3 migration re-stamps the
|
|
// schema version and preserves every node / edge / repo / contract
|
|
// record. The record shapes are identical across the two versions, so
|
|
// a v2 stream encoded with the current types and SchemaVersion=2
|
|
// faithfully stands in for a real on-disk v2 snapshot.
|
|
func TestMigrateSnapshotV2toV3(t *testing.T) {
|
|
var v2 bytes.Buffer
|
|
enc := gob.NewEncoder(&v2)
|
|
hdr := snapshotHeader{
|
|
SchemaVersion: 2,
|
|
Version: version,
|
|
NodeCount: 2,
|
|
EdgeCount: 1,
|
|
RepoCount: 1,
|
|
}
|
|
require.NoError(t, enc.Encode(hdr))
|
|
require.NoError(t, enc.Encode(graph.Node{ID: "a.go::Foo", Name: "Foo", Kind: graph.KindFunction, FilePath: "a.go"}))
|
|
require.NoError(t, enc.Encode(graph.Node{ID: "b.go::Bar", Name: "Bar", Kind: graph.KindMethod, FilePath: "b.go"}))
|
|
require.NoError(t, enc.Encode(graph.Edge{From: "b.go::Bar", To: "a.go::Foo", Kind: graph.EdgeCalls}))
|
|
require.NoError(t, enc.Encode(snapshotRepo{RepoPrefix: "repo", RootPath: "/tmp/repo"}))
|
|
|
|
var v3 bytes.Buffer
|
|
require.NoError(t, migrateSnapshotV2toV3(&v2, &v3))
|
|
|
|
dec := gob.NewDecoder(&v3)
|
|
var migrated snapshotHeader
|
|
require.NoError(t, dec.Decode(&migrated))
|
|
assert.Equal(t, 3, migrated.SchemaVersion, "migration must bump the schema version")
|
|
assert.Equal(t, 2, migrated.NodeCount, "node count must survive")
|
|
assert.Equal(t, 1, migrated.RepoCount, "repo count must survive")
|
|
|
|
for i := 0; i < migrated.NodeCount; i++ {
|
|
var n graph.Node
|
|
require.NoError(t, dec.Decode(&n), "every node record must decode from the migrated stream")
|
|
assert.NotEmpty(t, n.ID)
|
|
}
|
|
for i := 0; i < migrated.EdgeCount; i++ {
|
|
var e graph.Edge
|
|
require.NoError(t, dec.Decode(&e))
|
|
assert.Equal(t, "b.go::Bar", e.From)
|
|
}
|
|
for i := 0; i < migrated.RepoCount; i++ {
|
|
var r snapshotRepo
|
|
require.NoError(t, dec.Decode(&r))
|
|
assert.Equal(t, "repo", r.RepoPrefix)
|
|
}
|
|
}
|
|
|
|
// TestSnapshotRoundTrip_NoVectorIndex asserts a snapshot written with
|
|
// no vector data loads with an empty snapshotVector — the embeddings-
|
|
// disabled path must not synthesise a phantom index.
|
|
func TestSnapshotRoundTrip_NoVectorIndex(t *testing.T) {
|
|
dir := t.TempDir()
|
|
t.Setenv("GORTEX_DAEMON_SNAPSHOT", filepath.Join(dir, "snap.gob.gz"))
|
|
|
|
orig := graph.New()
|
|
orig.AddNode(&graph.Node{ID: "a.go::Foo", Name: "Foo", Kind: graph.KindFunction, FilePath: "a.go"})
|
|
saveSnapshot(orig, nil, nil, snapshotVector{}, version, zap.NewNop())
|
|
|
|
restored := graph.New()
|
|
result, err := loadSnapshot(restored, zap.NewNop())
|
|
require.NoError(t, err)
|
|
require.True(t, result.Loaded)
|
|
|
|
assert.Nil(t, result.Vector.Index, "no vector data written → none restored")
|
|
assert.Zero(t, result.Vector.Count)
|
|
}
|
|
|
|
// TestLoadSnapshot_DropsStaleAbsPathNodes guards against the T0.2 symptom
|
|
// of duplicate symbols leaking across daemon sessions. Prior-version code
|
|
// paths occasionally stored nodes with absolute filesystem paths as their
|
|
// IDs; those nodes persisted in snapshots and were restored forever
|
|
// alongside the correctly-prefixed nodes produced by current indexing.
|
|
// loadSnapshot must detect the stale shape and drop it so re-indexing
|
|
// yields a single canonical node per symbol.
|
|
func TestLoadSnapshot_DropsStaleAbsPathNodes(t *testing.T) {
|
|
dir := t.TempDir()
|
|
t.Setenv("GORTEX_DAEMON_SNAPSHOT", filepath.Join(dir, "snap.gob.gz"))
|
|
|
|
orig := graph.New()
|
|
// Clean, current-shape node — should be restored.
|
|
orig.AddNode(&graph.Node{
|
|
ID: "core-api/api/handler.go::Handler.CreateTuck",
|
|
Name: "CreateTuck", Kind: graph.KindMethod,
|
|
FilePath: "core-api/api/handler.go", RepoPrefix: "core-api",
|
|
})
|
|
// Stale abs-path node — a duplicate of the clean one, must be dropped.
|
|
orig.AddNode(&graph.Node{
|
|
ID: "/Users/me/tuck/core-api/api/handler.go::Handler.CreateTuck",
|
|
Name: "CreateTuck", Kind: graph.KindMethod,
|
|
FilePath: "/Users/me/tuck/core-api/api/handler.go",
|
|
})
|
|
// Edge pointing at the stale node — must be dropped too so the
|
|
// restored graph contains no dangling references.
|
|
orig.AddEdge(&graph.Edge{
|
|
From: "core-api/api/handler.go::Handler.RegisterRoutes",
|
|
To: "/Users/me/tuck/core-api/api/handler.go::Handler.CreateTuck",
|
|
Kind: graph.EdgeCalls,
|
|
})
|
|
// Edge between two clean nodes — must survive.
|
|
orig.AddNode(&graph.Node{
|
|
ID: "core-api/api/handler.go::Handler.RegisterRoutes",
|
|
Name: "RegisterRoutes", Kind: graph.KindMethod,
|
|
FilePath: "core-api/api/handler.go", RepoPrefix: "core-api",
|
|
})
|
|
orig.AddEdge(&graph.Edge{
|
|
From: "core-api/api/handler.go::Handler.RegisterRoutes",
|
|
To: "core-api/api/handler.go::Handler.CreateTuck",
|
|
Kind: graph.EdgeCalls,
|
|
})
|
|
|
|
saveSnapshot(orig, nil, nil, snapshotVector{}, version, zap.NewNop())
|
|
|
|
restored := graph.New()
|
|
result, err := loadSnapshot(restored, zap.NewNop())
|
|
require.NoError(t, err)
|
|
require.True(t, result.Loaded)
|
|
|
|
assert.NotNil(t, restored.GetNode("core-api/api/handler.go::Handler.CreateTuck"),
|
|
"clean prefixed node must be restored")
|
|
assert.Nil(t, restored.GetNode("/Users/me/tuck/core-api/api/handler.go::Handler.CreateTuck"),
|
|
"stale abs-path node must be dropped on load")
|
|
|
|
for _, e := range restored.AllEdges() {
|
|
assert.False(t, strings.HasPrefix(e.From, "/"),
|
|
"edge From references a dropped stale node: %s → %s", e.From, e.To)
|
|
assert.False(t, strings.HasPrefix(e.To, "/"),
|
|
"edge To references a dropped stale node: %s → %s", e.From, e.To)
|
|
}
|
|
}
|
|
|
|
// TestSnapshotRoundTrip_NodeMetaWithShape guards against regressing on
|
|
// gob-registration of *contracts.Shape. The indexer attaches a Shape to
|
|
// contract-referenced type nodes via Meta["shape"]; without the Register
|
|
// call, saveSnapshot aborts on the first such node with "type not
|
|
// registered for interface: contracts.Shape" and the snapshot file is
|
|
// never written.
|
|
func TestSnapshotRoundTrip_NodeMetaWithShape(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "snap.gob.gz")
|
|
t.Setenv("GORTEX_DAEMON_SNAPSHOT", path)
|
|
|
|
orig := graph.New()
|
|
orig.AddNode(&graph.Node{
|
|
ID: "a.go::Req",
|
|
Name: "Req",
|
|
Kind: graph.KindType,
|
|
FilePath: "a.go",
|
|
Meta: map[string]any{
|
|
"shape": &contracts.Shape{
|
|
Kind: "struct",
|
|
Fields: []contracts.ShapeField{
|
|
{Name: "ID", Type: "string", Required: true},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
saveSnapshot(orig, nil, nil, snapshotVector{}, version, zap.NewNop())
|
|
|
|
info, err := os.Stat(path)
|
|
require.NoError(t, err, "snapshot file must exist — encode must not have aborted")
|
|
require.Greater(t, info.Size(), int64(0), "snapshot must not be empty")
|
|
|
|
restored := graph.New()
|
|
result, err := loadSnapshot(restored, zap.NewNop())
|
|
require.NoError(t, err)
|
|
require.True(t, result.Loaded)
|
|
|
|
n := restored.GetNode("a.go::Req")
|
|
require.NotNil(t, n)
|
|
require.NotNil(t, n.Meta["shape"], "shape must survive round-trip")
|
|
|
|
shape, ok := n.Meta["shape"].(*contracts.Shape)
|
|
require.True(t, ok, "decoded shape must keep its concrete *contracts.Shape type, got %T", n.Meta["shape"])
|
|
assert.Equal(t, "struct", shape.Kind)
|
|
require.Len(t, shape.Fields, 1)
|
|
assert.Equal(t, "ID", shape.Fields[0].Name)
|
|
}
|
|
|
|
// TestSnapshotRoundTrip_NodeMetaWithGenericTypes guards the gob
|
|
// registration for the parser-side Meta values that aren't covered by
|
|
// the default map[string]any/[]any/[]string set: type_params (Go/TS/Rust
|
|
// generics, []map[string]string) and status_codes (HTTP contracts, []int).
|
|
// Without the registration, saveSnapshot aborts on the first such node
|
|
// with "type not registered for interface: []map[string]string" and the
|
|
// snapshot file is never written — a silent regression because
|
|
// saveSnapshot only logs.
|
|
func TestSnapshotRoundTrip_NodeMetaWithGenericTypes(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "snap.gob.gz")
|
|
t.Setenv("GORTEX_DAEMON_SNAPSHOT", path)
|
|
|
|
orig := graph.New()
|
|
orig.AddNode(&graph.Node{
|
|
ID: "a.go::Generic",
|
|
Name: "Generic",
|
|
Kind: graph.KindFunction,
|
|
FilePath: "a.go",
|
|
Meta: map[string]any{
|
|
"type_params": []map[string]string{
|
|
{"name": "T", "bound": "comparable"},
|
|
{"name": "U"},
|
|
},
|
|
"status_codes": []int{200, 404, 500},
|
|
},
|
|
})
|
|
|
|
saveSnapshot(orig, nil, nil, snapshotVector{}, version, zap.NewNop())
|
|
|
|
info, err := os.Stat(path)
|
|
require.NoError(t, err, "snapshot file must exist — encode must not have aborted")
|
|
require.Greater(t, info.Size(), int64(0), "snapshot must not be empty")
|
|
|
|
restored := graph.New()
|
|
result, err := loadSnapshot(restored, zap.NewNop())
|
|
require.NoError(t, err)
|
|
require.True(t, result.Loaded)
|
|
|
|
n := restored.GetNode("a.go::Generic")
|
|
require.NotNil(t, n)
|
|
|
|
tp, ok := n.Meta["type_params"].([]map[string]string)
|
|
require.True(t, ok, "type_params must keep its concrete []map[string]string type, got %T", n.Meta["type_params"])
|
|
require.Len(t, tp, 2)
|
|
assert.Equal(t, "T", tp[0]["name"])
|
|
assert.Equal(t, "comparable", tp[0]["bound"])
|
|
|
|
sc, ok := n.Meta["status_codes"].([]int)
|
|
require.True(t, ok, "status_codes must keep its concrete []int type, got %T", n.Meta["status_codes"])
|
|
assert.Equal(t, []int{200, 404, 500}, sc)
|
|
}
|
|
|
|
// TestSnapshotRoundTrip_ContractMetaWithSliceOfMaps guards the gob
|
|
// registration of []map[string]any. The HTTP schema enricher writes a
|
|
// response_envelope value of that shape into Contract.Meta; without the
|
|
// Register call, saveSnapshot aborts on the first such contract with
|
|
// "type not registered for interface: []map[string]interface {}" and the
|
|
// snapshot file never lands.
|
|
func TestSnapshotRoundTrip_ContractMetaWithSliceOfMaps(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "snap.gob.gz")
|
|
t.Setenv("GORTEX_DAEMON_SNAPSHOT", path)
|
|
|
|
orig := graph.New()
|
|
snapContracts := []snapshotContract{{
|
|
ID: "repo/api.go::POST /tasks",
|
|
Type: "http",
|
|
Role: "provider",
|
|
SymbolID: "repo/api.go::CreateTask",
|
|
FilePath: "repo/api.go",
|
|
Line: 42,
|
|
RepoPrefix: "repo",
|
|
Meta: map[string]any{
|
|
"path": "/tasks",
|
|
"response_envelope": []map[string]any{
|
|
{"name": "id", "type": "string"},
|
|
{"name": "items", "type": "Task", "repeated": true},
|
|
},
|
|
},
|
|
}}
|
|
|
|
saveSnapshot(orig, nil, snapContracts, snapshotVector{}, version, zap.NewNop())
|
|
|
|
info, err := os.Stat(path)
|
|
require.NoError(t, err, "snapshot file must exist — encode must not have aborted on []map[string]any")
|
|
require.Greater(t, info.Size(), int64(0))
|
|
|
|
restored := graph.New()
|
|
result, err := loadSnapshot(restored, zap.NewNop())
|
|
require.NoError(t, err)
|
|
require.True(t, result.Loaded)
|
|
|
|
cs, ok := result.Contracts["repo"]
|
|
require.True(t, ok, "contracts for repo prefix must round-trip")
|
|
require.Len(t, cs, 1)
|
|
env, ok := cs[0].Meta["response_envelope"].([]map[string]any)
|
|
require.True(t, ok, "response_envelope must keep its []map[string]any type, got %T", cs[0].Meta["response_envelope"])
|
|
require.Len(t, env, 2)
|
|
assert.Equal(t, "id", env[0]["name"])
|
|
assert.Equal(t, true, env[1]["repeated"])
|
|
}
|
|
|
|
func TestLoadSnapshot_MissingFile_NotAnError(t *testing.T) {
|
|
dir := t.TempDir()
|
|
t.Setenv("GORTEX_DAEMON_SNAPSHOT", filepath.Join(dir, "nope.gob.gz"))
|
|
|
|
g := graph.New()
|
|
result, err := loadSnapshot(g, zap.NewNop())
|
|
require.NoError(t, err, "missing snapshot must not surface as an error — first-run path")
|
|
assert.False(t, result.Loaded, "no snapshot means loaded=false")
|
|
assert.Equal(t, 0, g.NodeCount())
|
|
}
|
|
|
|
// Regression: a snapshot written by an older binary must not be loaded
|
|
// silently. Earlier symptoms — methods like (*Node).Inner showing zero
|
|
// callers even though the source has 19 call sites — traced back to the
|
|
// resolver having improved between daemon versions while the persisted
|
|
// edges kept their older (wrong) targets forever. The version field on
|
|
// the snapshot header is what protects us; this test pins that gate.
|
|
func TestLoadSnapshot_BinaryVersionMismatch_Discards(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "older.gob.gz")
|
|
t.Setenv("GORTEX_DAEMON_SNAPSHOT", path)
|
|
|
|
// Write a snapshot stamped with a different binary version.
|
|
src := graph.New()
|
|
src.AddNode(&graph.Node{ID: "a.go::Foo", Kind: graph.KindFunction, Name: "Foo", FilePath: "a.go", Language: "go"})
|
|
require.NoError(t, saveSnapshotTo(src, nil, nil, snapshotVector{}, "0.0.0-from-an-older-daemon", path, zap.NewNop()))
|
|
|
|
g := graph.New()
|
|
result, err := loadSnapshot(g, zap.NewNop())
|
|
require.NoError(t, err, "version mismatch is not an error — daemon falls back to full re-index")
|
|
assert.False(t, result.Loaded, "older-binary snapshot must not be loaded into a newer daemon")
|
|
assert.Equal(t, 0, g.NodeCount(), "graph must remain empty so the warmup path re-indexes from source")
|
|
}
|
|
|
|
// Round-trip with the current binary's version stamp must succeed —
|
|
// version-gating mustn't break the normal "same-binary" path.
|
|
func TestLoadSnapshot_SameBinaryVersion_Loads(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "same.gob.gz")
|
|
t.Setenv("GORTEX_DAEMON_SNAPSHOT", path)
|
|
|
|
src := graph.New()
|
|
src.AddNode(&graph.Node{ID: "a.go::Foo", Kind: graph.KindFunction, Name: "Foo", FilePath: "a.go", Language: "go"})
|
|
require.NoError(t, saveSnapshotTo(src, nil, nil, snapshotVector{}, version, path, zap.NewNop()))
|
|
|
|
g := graph.New()
|
|
result, err := loadSnapshot(g, zap.NewNop())
|
|
require.NoError(t, err)
|
|
assert.True(t, result.Loaded)
|
|
assert.Equal(t, 1, g.NodeCount())
|
|
}
|
|
|
|
// Regression: when a snapshot's persisted resolution state is corrupt
|
|
// (lots of edges point at node IDs that no longer exist), the loader
|
|
// must discard it entirely instead of returning a half-graph that the
|
|
// daemon then layers incremental indexing on top of. We saw this in
|
|
// the wild — `(*Node).Type` and `WriteIfNotExists` ended up with zero
|
|
// callers in the daemon's graph despite obviously having dozens in
|
|
// source, because consecutive restarts kept loading and re-saving an
|
|
// increasingly degraded snapshot.
|
|
func TestLoadSnapshot_CorruptionDetected_ForcesFreshIndex(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "corrupt.gob.gz")
|
|
t.Setenv("GORTEX_DAEMON_SNAPSHOT", path)
|
|
|
|
// Build a snapshot with 200 nodes and 200 edges pointing at IDs
|
|
// that don't exist. >5% drop ratio + EdgeCount > 100 → corrupt path.
|
|
orig := graph.New()
|
|
for i := 0; i < 200; i++ {
|
|
orig.AddNode(&graph.Node{
|
|
ID: fmt.Sprintf("repo/a.go::Sym%d", i), Kind: graph.KindFunction,
|
|
Name: fmt.Sprintf("Sym%d", i), FilePath: "repo/a.go", Language: "go",
|
|
RepoPrefix: "repo", // real graphs always set this; tests must too
|
|
})
|
|
orig.AddEdge(&graph.Edge{
|
|
From: fmt.Sprintf("repo/a.go::Sym%d", i),
|
|
To: fmt.Sprintf("ghost.go::Ghost%d", i), // target node never exists
|
|
Kind: graph.EdgeCalls,
|
|
})
|
|
}
|
|
require.NoError(t, saveSnapshotTo(orig, nil, nil, snapshotVector{}, version, path, zap.NewNop()))
|
|
|
|
g := graph.New()
|
|
result, err := loadSnapshot(g, zap.NewNop())
|
|
require.NoError(t, err)
|
|
assert.False(t, result.Loaded, "corruption-detected snapshot must NOT be reported as loaded")
|
|
assert.Equal(t, 0, g.NodeCount(), "graph must be empty so warmup re-indexes from source")
|
|
|
|
_, statErr := os.Stat(path)
|
|
assert.True(t, os.IsNotExist(statErr),
|
|
"corrupt snapshot file must be deleted so a daemon restart doesn't loop on it")
|
|
}
|
|
|
|
// Regression guard for the abs-path cleanup case: a small number of
|
|
// stale-edge drops accompanied by a node-drop wave (the intentional
|
|
// pre-v1 abs-path cleanup) must NOT be flagged as corruption.
|
|
func TestLoadSnapshot_SmallStaleEdgeDrops_StaysLoaded(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "absclean.gob.gz")
|
|
t.Setenv("GORTEX_DAEMON_SNAPSHOT", path)
|
|
|
|
// 200 healthy entries plus 5 abs-path stale ones (under 5% drop).
|
|
orig := graph.New()
|
|
for i := 0; i < 200; i++ {
|
|
orig.AddNode(&graph.Node{
|
|
ID: fmt.Sprintf("a.go::S%d", i), Kind: graph.KindFunction,
|
|
Name: fmt.Sprintf("S%d", i), FilePath: "a.go", Language: "go",
|
|
})
|
|
}
|
|
for i := 0; i < 5; i++ {
|
|
orig.AddNode(&graph.Node{
|
|
ID: fmt.Sprintf("/abs/a.go::S%d", i), // stale abs-path: dropped on load
|
|
Kind: graph.KindFunction, Name: fmt.Sprintf("S%d", i),
|
|
FilePath: "/abs/a.go",
|
|
})
|
|
orig.AddEdge(&graph.Edge{
|
|
From: "a.go::S0", To: fmt.Sprintf("/abs/a.go::S%d", i), Kind: graph.EdgeCalls,
|
|
})
|
|
}
|
|
require.NoError(t, saveSnapshotTo(orig, nil, nil, snapshotVector{}, version, path, zap.NewNop()))
|
|
|
|
g := graph.New()
|
|
result, err := loadSnapshot(g, zap.NewNop())
|
|
require.NoError(t, err)
|
|
assert.True(t, result.Loaded, "small drop ratio must still load (abs-path cleanup is normal)")
|
|
assert.GreaterOrEqual(t, g.NodeCount(), 200)
|
|
}
|
|
|
|
func TestLoadSnapshot_CorruptFile_ReportsError(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "corrupt.gob.gz")
|
|
t.Setenv("GORTEX_DAEMON_SNAPSHOT", path)
|
|
require.NoError(t, os.WriteFile(path, []byte("not a gzip stream"), 0o600))
|
|
|
|
g := graph.New()
|
|
result, err := loadSnapshot(g, zap.NewNop())
|
|
assert.Error(t, err, "corrupt snapshot must not be silently swallowed")
|
|
assert.False(t, result.Loaded)
|
|
assert.Equal(t, 0, g.NodeCount())
|
|
}
|
|
|
|
// TestSaveSnapshot_ShrinkGuardBlocksCollapse proves the shrink-guard
|
|
// refuses to overwrite a healthy snapshot with one whose node/edge
|
|
// counts have collapsed — the signature of a partial or failed index.
|
|
// The prior good snapshot must survive untouched on disk.
|
|
func TestSaveSnapshot_ShrinkGuardBlocksCollapse(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "snap.gob.gz")
|
|
|
|
// A healthy baseline snapshot: 200 nodes, 199 edges.
|
|
healthy := graph.New()
|
|
for i := 0; i < 200; i++ {
|
|
healthy.AddNode(&graph.Node{
|
|
ID: fmt.Sprintf("repo/a.go::Sym%d", i), Kind: graph.KindFunction,
|
|
Name: fmt.Sprintf("Sym%d", i), FilePath: "repo/a.go", Language: "go",
|
|
RepoPrefix: "repo",
|
|
})
|
|
if i > 0 {
|
|
healthy.AddEdge(&graph.Edge{
|
|
From: fmt.Sprintf("repo/a.go::Sym%d", i),
|
|
To: "repo/a.go::Sym0", Kind: graph.EdgeCalls,
|
|
})
|
|
}
|
|
}
|
|
require.NoError(t, saveSnapshotTo(healthy, nil, nil, snapshotVector{}, version, path, zap.NewNop()))
|
|
|
|
// A collapsed graph — 10 nodes, ~5% of the baseline. Attempting to
|
|
// persist it must be refused.
|
|
collapsed := graph.New()
|
|
for i := 0; i < 10; i++ {
|
|
collapsed.AddNode(&graph.Node{
|
|
ID: fmt.Sprintf("repo/a.go::Sym%d", i), Kind: graph.KindFunction,
|
|
Name: fmt.Sprintf("Sym%d", i), FilePath: "repo/a.go", Language: "go",
|
|
RepoPrefix: "repo",
|
|
})
|
|
}
|
|
// The guard's refusal is not surfaced as an error — keeping the
|
|
// good snapshot is a success outcome.
|
|
require.NoError(t, saveSnapshotTo(collapsed, nil, nil, snapshotVector{}, version, path, zap.NewNop()))
|
|
|
|
// The on-disk snapshot must still be the healthy 200-node one.
|
|
hdr, ok := readSnapshotHeader(path)
|
|
require.True(t, ok, "the prior good snapshot must still be readable")
|
|
assert.Equal(t, 200, hdr.NodeCount, "shrink-guard must have preserved the healthy snapshot")
|
|
assert.Equal(t, 199, hdr.EdgeCount, "shrink-guard must have preserved the healthy snapshot")
|
|
|
|
// And no leftover .tmp file from the refused write.
|
|
_, statErr := os.Stat(path + ".tmp")
|
|
assert.True(t, os.IsNotExist(statErr), "the refused write's tmp file must be cleaned up")
|
|
}
|
|
|
|
// TestSaveSnapshot_ShrinkGuardAllowsModestShrink proves a genuine,
|
|
// moderate shrink (files deleted) still overwrites the prior snapshot —
|
|
// the guard blocks only a suspicious collapse, not normal churn.
|
|
func TestSaveSnapshot_ShrinkGuardAllowsModestShrink(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "snap.gob.gz")
|
|
|
|
big := graph.New()
|
|
for i := 0; i < 200; i++ {
|
|
big.AddNode(&graph.Node{
|
|
ID: fmt.Sprintf("repo/a.go::Sym%d", i), Kind: graph.KindFunction,
|
|
Name: fmt.Sprintf("Sym%d", i), FilePath: "repo/a.go", Language: "go",
|
|
RepoPrefix: "repo",
|
|
})
|
|
}
|
|
require.NoError(t, saveSnapshotTo(big, nil, nil, snapshotVector{}, version, path, zap.NewNop()))
|
|
|
|
// 150 nodes — 75% retained, well above the 50% floor. A legitimate
|
|
// shrink that must be allowed through.
|
|
smaller := graph.New()
|
|
for i := 0; i < 150; i++ {
|
|
smaller.AddNode(&graph.Node{
|
|
ID: fmt.Sprintf("repo/a.go::Sym%d", i), Kind: graph.KindFunction,
|
|
Name: fmt.Sprintf("Sym%d", i), FilePath: "repo/a.go", Language: "go",
|
|
RepoPrefix: "repo",
|
|
})
|
|
}
|
|
require.NoError(t, saveSnapshotTo(smaller, nil, nil, snapshotVector{}, version, path, zap.NewNop()))
|
|
|
|
hdr, ok := readSnapshotHeader(path)
|
|
require.True(t, ok)
|
|
assert.Equal(t, 150, hdr.NodeCount, "a modest shrink must be allowed to overwrite the snapshot")
|
|
}
|
|
|
|
// TestSaveSnapshot_ShrinkGuardAllowsFirstWrite proves the guard never
|
|
// blocks the very first snapshot — with no prior file there is no good
|
|
// snapshot to protect.
|
|
func TestSaveSnapshot_ShrinkGuardAllowsFirstWrite(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "fresh.gob.gz")
|
|
|
|
g := graph.New()
|
|
g.AddNode(&graph.Node{ID: "a.go::Foo", Kind: graph.KindFunction, Name: "Foo", FilePath: "a.go", Language: "go"})
|
|
require.NoError(t, saveSnapshotTo(g, nil, nil, snapshotVector{}, version, path, zap.NewNop()))
|
|
|
|
hdr, ok := readSnapshotHeader(path)
|
|
require.True(t, ok, "the first snapshot must be written even though it is small")
|
|
assert.Equal(t, 1, hdr.NodeCount)
|
|
}
|
|
|
|
// TestSnapshotWouldCollapse_EdgeOnlyCollapseIsBlocked proves the guard
|
|
// fires when edges collapse even though nodes are retained — a resolver
|
|
// failure can shed the call graph while leaving symbol nodes intact.
|
|
func TestSnapshotWouldCollapse_EdgeOnlyCollapseIsBlocked(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "snap.gob.gz")
|
|
|
|
healthy := graph.New()
|
|
for i := 0; i < 100; i++ {
|
|
healthy.AddNode(&graph.Node{
|
|
ID: fmt.Sprintf("repo/a.go::Sym%d", i), Kind: graph.KindFunction,
|
|
Name: fmt.Sprintf("Sym%d", i), FilePath: "repo/a.go", Language: "go",
|
|
RepoPrefix: "repo",
|
|
})
|
|
if i > 0 {
|
|
healthy.AddEdge(&graph.Edge{
|
|
From: fmt.Sprintf("repo/a.go::Sym%d", i),
|
|
To: "repo/a.go::Sym0", Kind: graph.EdgeCalls,
|
|
})
|
|
}
|
|
}
|
|
require.NoError(t, saveSnapshotTo(healthy, nil, nil, snapshotVector{}, version, path, zap.NewNop()))
|
|
|
|
// All 100 nodes retained, but only 5 of 99 edges — the call graph
|
|
// has collapsed. The guard must catch the edge-side collapse.
|
|
if !snapshotWouldCollapse(path, 100, 5, zap.NewNop()) {
|
|
t.Error("a collapse confined to edges must still be blocked")
|
|
}
|
|
// Both counts healthy → allowed.
|
|
if snapshotWouldCollapse(path, 100, 99, zap.NewNop()) {
|
|
t.Error("an unchanged graph must not be flagged as a collapse")
|
|
}
|
|
}
|
|
|
|
// TestStartPeriodicSnapshots_WritesOnTick verifies the 10-minute ticker
|
|
// (configurable interval in tests) actually fires and writes to disk.
|
|
// The daemon relies on this for crash-recovery — on a `kill -9` it
|
|
// would otherwise lose everything since the last shutdown snapshot.
|
|
func TestStartPeriodicSnapshots_WritesOnTick(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "periodic.gob.gz")
|
|
t.Setenv("GORTEX_DAEMON_SNAPSHOT", path)
|
|
|
|
g := graph.New()
|
|
g.AddNode(&graph.Node{ID: "a.go::X", Name: "X", Kind: graph.KindFunction, FilePath: "a.go"})
|
|
|
|
// 30ms interval — fast enough to observe two or three ticks within
|
|
// a reasonable test budget, slow enough to survive scheduler jitter.
|
|
// nil isReady → always-ready (test wants ticks to fire immediately).
|
|
stop := startPeriodicSnapshots(g, nil, "t", 30*time.Millisecond, nil, zap.NewNop())
|
|
t.Cleanup(stop)
|
|
|
|
require.Eventually(t, func() bool {
|
|
info, err := os.Stat(path)
|
|
return err == nil && info.Size() > 0
|
|
}, 2*time.Second, 20*time.Millisecond,
|
|
"periodic snapshot should land on disk within the budget")
|
|
|
|
// Prove a second tick also happens — modify mtime check after capture.
|
|
info1, err := os.Stat(path)
|
|
require.NoError(t, err)
|
|
|
|
// Add another node to force a different encoded payload on the
|
|
// next tick; this way a no-op snapshot won't silently pass by
|
|
// just checking mtime equality.
|
|
g.AddNode(&graph.Node{ID: "b.go::Y", Name: "Y", Kind: graph.KindFunction, FilePath: "b.go"})
|
|
|
|
require.Eventually(t, func() bool {
|
|
info2, err := os.Stat(path)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return info2.ModTime().After(info1.ModTime())
|
|
}, 2*time.Second, 20*time.Millisecond,
|
|
"second periodic tick should rewrite the snapshot")
|
|
}
|