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
176 lines
6.7 KiB
Go
176 lines
6.7 KiB
Go
package main
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"reflect"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/zzet/gortex/internal/graph"
|
|
)
|
|
|
|
// TestWireContractFingerprint is the schema-stability guard for the
|
|
// daemon's snapshot wire format. It fingerprints the exported fields of
|
|
// every struct that gets gob-encoded into the snapshot and compares the
|
|
// hash against a checked-in golden value. Any change to a field name,
|
|
// type, or set-membership shifts the hash.
|
|
//
|
|
// When this test fails:
|
|
//
|
|
// 1. Additive change (new field only, existing fields untouched) —
|
|
// update the golden value below. Old snapshots still load because
|
|
// gob decodes unknown fields as zero.
|
|
//
|
|
// 2. Breaking change (rename, remove, retype an existing field) —
|
|
// bump snapshotSchemaVersion in daemon_snapshot.go AND register a
|
|
// migration in snapshotMigrations, THEN update the golden value.
|
|
// Without a migration, deployed daemons reading an old snapshot
|
|
// will discard the cache on upgrade and pay the full-reindex cost.
|
|
//
|
|
// Runs as part of the existing `go test ./...` sweep, no extra CI
|
|
// infrastructure required.
|
|
func TestWireContractFingerprint(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
typ reflect.Type
|
|
want string
|
|
}{
|
|
{"graph.Node", reflect.TypeOf(graph.Node{}), ""},
|
|
{"graph.Edge", reflect.TypeOf(graph.Edge{}), ""},
|
|
{"snapshotHeader", reflect.TypeOf(snapshotHeader{}), ""},
|
|
{"snapshotRepo", reflect.TypeOf(snapshotRepo{}), ""},
|
|
{"snapshotContract", reflect.TypeOf(snapshotContract{}), ""},
|
|
}
|
|
|
|
// Golden values computed by fingerprintType against the current
|
|
// struct definitions. When a change to any of these structs is
|
|
// intentional, update this map with the new values (run the test
|
|
// once, copy the "got" hash from the failure message).
|
|
golden := map[string]string{
|
|
"graph.Node": wireContractGolden("graph.Node"),
|
|
"graph.Edge": wireContractGolden("graph.Edge"),
|
|
"snapshotHeader": wireContractGolden("snapshotHeader"),
|
|
"snapshotRepo": wireContractGolden("snapshotRepo"),
|
|
"snapshotContract": wireContractGolden("snapshotContract"),
|
|
}
|
|
|
|
for i := range cases {
|
|
cases[i].want = golden[cases[i].name]
|
|
}
|
|
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
got := fingerprintType(c.typ)
|
|
if got != c.want {
|
|
t.Errorf(`wire contract for %s changed.
|
|
got: %s
|
|
want: %s
|
|
|
|
If the change is ADDITIVE (new field, existing fields untouched):
|
|
• Old snapshots still decode cleanly (gob reads unknown fields as zero).
|
|
• Update the golden fingerprint in wireContractGolden().
|
|
|
|
If the change RENAMES / REMOVES / RETYPES an existing field:
|
|
• Old snapshots will fail to decode cleanly on the changed field.
|
|
• Bump snapshotSchemaVersion in daemon_snapshot.go.
|
|
• Register a migration in snapshotMigrations for the old→new version.
|
|
• Then update the golden fingerprint.
|
|
|
|
Field set: %s`, c.name, got, c.want, describeFields(c.typ))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// fingerprintType returns a stable SHA-256 over the exported-field set
|
|
// of t. Field order is NOT part of the fingerprint (gob identifies
|
|
// fields by name), but name + type are. Nested structs are captured by
|
|
// their type.String() — if a nested type's own shape changes, callers
|
|
// with their own fingerprint will catch it.
|
|
func fingerprintType(t reflect.Type) string {
|
|
if t.Kind() != reflect.Struct {
|
|
return ""
|
|
}
|
|
fields := make([]string, 0, t.NumField())
|
|
for i := 0; i < t.NumField(); i++ {
|
|
f := t.Field(i)
|
|
if !f.IsExported() {
|
|
continue
|
|
}
|
|
fields = append(fields, f.Name+":"+f.Type.String())
|
|
}
|
|
sort.Strings(fields)
|
|
h := sha256.Sum256([]byte(strings.Join(fields, "|")))
|
|
return hex.EncodeToString(h[:])
|
|
}
|
|
|
|
// describeFields renders the exported-field set for failure messages.
|
|
// Matches fingerprintType's canonical form so the output is a drop-in
|
|
// replacement for the hash during debugging.
|
|
func describeFields(t reflect.Type) string {
|
|
if t.Kind() != reflect.Struct {
|
|
return "(not a struct)"
|
|
}
|
|
fields := make([]string, 0, t.NumField())
|
|
for i := 0; i < t.NumField(); i++ {
|
|
f := t.Field(i)
|
|
if !f.IsExported() {
|
|
continue
|
|
}
|
|
fields = append(fields, f.Name+":"+f.Type.String())
|
|
}
|
|
sort.Strings(fields)
|
|
return strings.Join(fields, ", ")
|
|
}
|
|
|
|
// wireContractGolden holds the expected fingerprint for each wire
|
|
// type. Updated intentionally when a struct changes; see the doc on
|
|
// TestWireContractFingerprint for the decision tree (additive update vs
|
|
// schema bump + migration). Values are pinned hashes — NOT recomputed
|
|
// from the live type — so a field-level drift will surface here.
|
|
func wireContractGolden(name string) string {
|
|
switch name {
|
|
case "graph.Node":
|
|
// Bumped when the source column-offset fields StartColumn / EndColumn
|
|
// were added (promoted to typed nodes columns on the SQLite backend).
|
|
// Additive: gob decodes unknown fields as zero, so older snapshots
|
|
// still load with them blank.
|
|
// Previously bumped when the federation proxy-node fields Origin /
|
|
// Stub / FetchedAt, then AbsoluteFilePath, then WorkspaceID /
|
|
// ProjectID, were added.
|
|
return "a600bd906c9e09ffa0828427f5d6622acd5fe244798a61d5712c151053be5dac"
|
|
case "graph.Edge":
|
|
// Bumped when Alias was added — the renamed name carried by a
|
|
// per-binding import (`import { x as alias }`) or a re-export
|
|
// (`export { x as alias } from`) edge; empty on every other edge.
|
|
// Additive: gob decodes older snapshots with Alias blank; not part of
|
|
// the edge identity / dedup key.
|
|
// (Previously bumped when Via, ReturnUsage, Context, then Tier, were added.)
|
|
return "4e51a5ea5eea74a33875b43baf9b6d80bf9609ae2befc16dd576132e09ea3d09"
|
|
case "snapshotHeader":
|
|
// Bumped when the VectorIndex / VectorDims / VectorCount fields
|
|
// were added (additive — gob decodes unknown fields as zero).
|
|
// This change shipped alongside snapshotSchemaVersion v3 and a
|
|
// v2→v3 migration so deployed daemons keep their v2 snapshots
|
|
// across the upgrade instead of paying a full re-index.
|
|
// Previously bumped when BinaryMtimeUnix was added.
|
|
return "a0f604616d04c757ffec4d084fc53cf6c3db03c8add1a8af0f32f902c1b1fe92"
|
|
case "snapshotRepo":
|
|
// Bumped when the per-repo NodeCount / EdgeCount fields were added —
|
|
// the baseline the boot shape-degradation guard compares a reloaded
|
|
// repo against. Additive: gob decodes older snapshots with both zero,
|
|
// which the guard reads as "no baseline to compare" and skips.
|
|
return "ddb90a625b70b681e3ac4015db3192991c37e6cdf8b0edef501db282bdfe3de3"
|
|
case "snapshotContract":
|
|
// New wire type introduced with per-repo contract persistence.
|
|
// Mirrors contracts.Contract but stores Type/Role as strings so
|
|
// the snapshot stays decoupled from runtime type aliases.
|
|
return "17b073a2f334b46d5f360a15f3d5d1617bee20660221f1de513735cedd75799c"
|
|
default:
|
|
panic(fmt.Sprintf("no golden fingerprint for %s", name))
|
|
}
|
|
}
|