Files
OwnCord/Server/ws/protocol_contract_test.go
T
Claude 63b522494c refactor: move the protocol schema to protocol/schema.json (RL-09)
The WebSocket message-type schema is the one artifact in this repository that
neither component owns: `Server/ws/message_types.go` and
`Client/src/lib/protocolTypes.ts` are both generated from it, and neither may
be hand-edited. It nonetheless lived at `docs/protocol-schema.json` — filed
under the directory for prose, whose own README calls it "Reference" material
— and its generator lived at `Server/scripts/genprotocol/`, i.e. inside one of
the two consumers. Ownership was legible from neither location.

The obvious fix — move the generator to the repository root alongside the
schema, so the whole tool is at the cross-component boundary — is wrong here.
The generator is a Go `package main`, and Go modules are directory-rooted:
`Server/go.mod` roots at `Server/`, so a root-level Go program needs a second
module or a `go.work`. That second module would sit outside every path filter
this repository already has — `golangci-lint` runs with `working-directory:
Server/` (ci.yml), `go vet ./...` runs from `Server/` (scripts/run.mjs,
.githooks/pre-commit), `.githooks/pre-commit` selects Go files with
`^Server/.*\.go$`, `.githooks/pre-push` sets `server_changed` on `^Server/`,
setup-go caches on `Server/go.sum`, and dependabot has one gomod block for
`/Server`. Six gates would silently stop covering the generator, each failing
open. The schema is data and moves freely; the generator is Go and stays where
the Go toolchain already runs.

Done instead:
- `docs/protocol-schema.json` -> `protocol/schema.json`. A new top-level
  `protocol/` is the cross-component boundary, with a `README.md` naming the
  two generated consumers, the one command, and the four gates.
- `Server/scripts/genprotocol/` -> `Server/cmd/genprotocol/`, the module's
  conventional home for an executable. This also empties `Server/scripts/` of
  Go entry points except `seed.go`, which RL-10 moves next.
- `Server/cmd/` added to `Server/.dockerignore` and `Server/.air.toml`, which
  both already excluded `Server/scripts/`. Without this the move would have
  silently widened the Docker build context and the air watch set.

27 files, 115 insertions, 76 deletions. Two runtime path resolvers re-pointed
(`cmd/genprotocol/main.go:41` `-schema` default, `ws/protocol_contract_test.go:67`
`filepath.Join`); two git-hook grep patterns (`pre-commit:53`, `pre-push:57`);
eight generator call sites across five files (Makefile x2, scripts/run.mjs x2,
pre-commit x2, ci-check skill, bughunt-fix.js); two broken relative markdown
links (docs/README.md:47, docs/protocol.md:1497); two generated files
regenerated, header lines only, zero constants changed; two ledger prose hits
plus a `render-ledger.mjs` re-render. No new verify was written: the
regenerate-and-diff check is already enforced three times (CI `make
protocol-verify`, `.githooks/pre-commit`, `npm run check:server`) and
`ws/protocol_contract_test.go` independently checks the schema against the
constants a fourth time.

Verified: both directions, for both resolvers. With `protocol/schema.json`
removed, `go test ./ws/ -run TestProtocol` fails with `reading protocol schema
at /home/user/OwnCord/protocol/schema.json: no such file or directory` (two
tests) and `go run ./cmd/genprotocol` exits 1 with `read schema: open
../protocol/schema.json: no such file or directory`; with the file restored
both pass. So the new path is genuinely resolved, not merely spelled in a
comment. The hook patterns were exercised directly: the pre-commit pattern
matches `protocol/schema.json` and `Server/cmd/genprotocol/main.go` and no
longer matches `docs/protocol-schema.json`; the pre-push pattern matches
`protocol/schema.json`. `go run ./cmd/genprotocol` twice in a row leaves
`git diff --exit-code ws/message_types.go ../Client/src/lib/protocolTypes.ts`
clean, so the committed outputs are exactly what the generator emits.
`go build ./...` and `go vet ./...` pass; `npx prettier --check .`,
`npm run typecheck` and `npm run lint` pass; `node .superpowers/render-ledger.mjs
--check` reports 348 findings valid.

Not included: the four dated `docs/audit-*.md` files, the older
`docs/plans/*`, and `CHANGELOG.md` keep the old path — they are point-in-time
records, and `.prettierignore` and `scripts/check-doc-counts.mjs` already
treat them as deliberately unmaintained. The B1 plan itself keeps its own
wording, since it states intent rather than current state. `Server/scripts/`
is not deleted: it still holds `seed.go` (RL-10), `k6/`, `toxiproxy/` and two
shell scripts. `Server/telemetry/metrics.go:19` declares a scope for a
`Server/voice` package that does not exist — spotted here, unrelated to this
move, left for RL-13's sweep to carry forward verbatim rather than fixed
inside a relocation. No `seed:` Make target was added.

Refs RL-09, L-09
2026-08-26 19:37:11 +00:00

226 lines
7.9 KiB
Go

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