Files
zzet--gortex/cmd/gortex/wire_contract_test.go
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

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))
}
}