mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
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
226 lines
7.9 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|