package ws_test // protocol_contract_test.go — locks protocol/schema.json and the // generated ws/message_types.go together so the two cannot silently drift. // // message_types.go is documented as "Code generated by cmd/genprotocol // from protocol/schema.json; DO NOT EDIT" and CI runs // `make protocol-verify`, but that only re-runs the generator and diffs its // output — it says nothing about message-type constants that exist in the ws // package outside the generated file (e.g. a handler defining its own // MsgTypeFoo instead of adding it to the schema). This test instead parses // both the schema and every non-test .go file in this package for `MsgType*` // string constants and asserts they agree in both directions: // // - every wire value the schema lists has a matching Go constant with the // schema's stated name and value ("schema -> code"), and // - every MsgType* constant declared anywhere in the ws package appears in // the schema ("code -> schema"). // // The exception list below is empty and should stay that way. Its one // historical entry, MsgTypeChatCommand, predated the schema/genprotocol // pipeline (plugin slash commands were judged internal wiring); the 2026-08-04 // remediation moved the whole plugin command family (chat_command, // command_reply, plugin_broadcast) into the schema, closing DC-01. If an // undocumented constant ever appears, this test fails and names it — that is // the drift this test exists to catch, not something to silently allowlist. import ( "encoding/json" "go/ast" "go/parser" "go/token" "os" "path/filepath" "runtime" "strconv" "strings" "testing" ) var knownUndocumentedConstants = map[string]string{} // protocolSchemaEntry mirrors one element of the client_to_server / // server_to_client arrays in protocol/schema.json. type protocolSchemaEntry struct { Wire string `json:"wire"` Go string `json:"go"` TS string `json:"ts"` } type protocolSchema struct { Version int `json:"version"` ClientToServer []protocolSchemaEntry `json:"client_to_server"` ServerToClient []protocolSchemaEntry `json:"server_to_client"` } // loadProtocolSchema locates and parses protocol/schema.json relative to // this test file (ws/ -> Server/ -> repo root -> protocol/), so the test does not // depend on the working directory `go test` happens to be invoked from. func loadProtocolSchema(t *testing.T) protocolSchema { t.Helper() _, thisFile, _, ok := runtime.Caller(0) if !ok { t.Fatal("runtime.Caller failed to resolve test file path") } wsDir := filepath.Dir(thisFile) schemaPath := filepath.Join(wsDir, "..", "..", "protocol", "schema.json") raw, err := os.ReadFile(schemaPath) if err != nil { t.Fatalf("reading protocol schema at %s: %v", schemaPath, err) } var schema protocolSchema if err := json.Unmarshal(raw, &schema); err != nil { t.Fatalf("parsing protocol schema: %v", err) } return schema } // loadGoMsgTypeConstants statically parses every non-test .go file in the ws // package directory and returns a map of MsgType* constant name -> its wire // string value, for top-level `const ( Name = "value" )` declarations. func loadGoMsgTypeConstants(t *testing.T) map[string]string { t.Helper() _, thisFile, _, ok := runtime.Caller(0) if !ok { t.Fatal("runtime.Caller failed to resolve test file path") } wsDir := filepath.Dir(thisFile) entries, err := os.ReadDir(wsDir) if err != nil { t.Fatalf("reading ws package directory %s: %v", wsDir, err) } out := make(map[string]string) fset := token.NewFileSet() for _, e := range entries { name := e.Name() if e.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { continue } file, err := parser.ParseFile(fset, filepath.Join(wsDir, name), nil, 0) if err != nil { t.Fatalf("parsing %s: %v", name, err) } for _, decl := range file.Decls { gen, isGen := decl.(*ast.GenDecl) if !isGen || gen.Tok != token.CONST { continue } for _, spec := range gen.Specs { vspec, isVal := spec.(*ast.ValueSpec) if !isVal { continue } for i, ident := range vspec.Names { if !strings.HasPrefix(ident.Name, "MsgType") { continue } if i >= len(vspec.Values) { continue // no explicit value on this line (e.g. iota-style) } lit, isLit := vspec.Values[i].(*ast.BasicLit) if !isLit || lit.Kind != token.STRING { continue } val, err := strconv.Unquote(lit.Value) if err != nil { t.Fatalf("unquoting %s = %s in %s: %v", ident.Name, lit.Value, name, err) } if existing, dup := out[ident.Name]; dup && existing != val { t.Fatalf("constant %s declared twice with different values (%q vs %q) across ws package files", ident.Name, existing, val) } out[ident.Name] = val } } } } return out } // TestProtocolSchema_MatchesGeneratedGoConstants is the "schema -> code" // direction: every wire constant protocol/schema.json lists (in either // direction of traffic) must have a same-named Go constant in the ws package // carrying exactly the schema's wire string. func TestProtocolSchema_MatchesGeneratedGoConstants(t *testing.T) { schema := loadProtocolSchema(t) goConsts := loadGoMsgTypeConstants(t) check := func(direction string, entries []protocolSchemaEntry) { for _, e := range entries { got, ok := goConsts[e.Go] if !ok { t.Errorf("%s: schema entry wire=%q go=%q has no matching Go constant in ws package", direction, e.Wire, e.Go) continue } if got != e.Wire { t.Errorf("%s: ws.%s = %q, want %q per protocol/schema.json", direction, e.Go, got, e.Wire) } } } if len(schema.ClientToServer) == 0 { t.Fatal("protocol/schema.json client_to_server is empty — schema failed to load") } if len(schema.ServerToClient) == 0 { t.Fatal("protocol/schema.json server_to_client is empty — schema failed to load") } check("client_to_server", schema.ClientToServer) check("server_to_client", schema.ServerToClient) } // TestProtocolSchema_NoUndocumentedGoConstants is the "code -> schema" // direction: every MsgType* constant declared in the ws package must appear // in protocol/schema.json, except the documented exceptions in // knownUndocumentedConstants (see its doc comment). This is what catches a // handler minting its own wire constant instead of adding it to the schema. func TestProtocolSchema_NoUndocumentedGoConstants(t *testing.T) { schema := loadProtocolSchema(t) goConsts := loadGoMsgTypeConstants(t) documented := make(map[string]string, len(schema.ClientToServer)+len(schema.ServerToClient)) for _, e := range schema.ClientToServer { documented[e.Go] = e.Wire } for _, e := range schema.ServerToClient { documented[e.Go] = e.Wire } for name, wire := range goConsts { if _, inSchema := documented[name]; inSchema { continue } exceptWire, isException := knownUndocumentedConstants[name] if !isException { t.Errorf("ws.%s = %q is not in protocol/schema.json and is not a documented exception "+ "(knownUndocumentedConstants) — add it to the schema or the exception list", name, wire) continue } if exceptWire != wire { t.Errorf("documented exception ws.%s has wire value %q, but knownUndocumentedConstants says %q — update the exception list", name, wire, exceptWire) } } // Guard against the exception list growing silently: it must contain // exactly the constants we can currently account for as intentionally // undocumented, and nothing that has since been added to the schema. for name := range knownUndocumentedConstants { if _, stillMissing := goConsts[name]; !stillMissing { t.Errorf("knownUndocumentedConstants lists %q but no such Go constant exists anymore — remove it from the exception list", name) continue } if _, nowDocumented := documented[name]; nowDocumented { t.Errorf("knownUndocumentedConstants lists %q but it is now in protocol/schema.json — remove it from the exception list", name) } } }