Files
zip/conformance_test.go
T
zeekay fb2b7f6d5c zip declares its ops in Rust, and projects them with the Go one's projector
A Rust developer writes Rust handlers, with Rust types and Rust doc comments,
and gets every projection from that source — the same deal a Go developer has
had. No .zap is written by hand and no Go program declares a Rust service's
ops.

The split falls on a measured seam. What is native per language is DECLARING an
op and RUNNING it; what is shared is the projection logic, driven from a neutral
description of the registry.

  Manifest      the registry with the Go taken out: ops, types, fields, wire
                names, prose. App.Manifest fills one in by reflection; a
                proc-macro fills one in as a Rust crate compiles.
  Project*      the OpenAPI document, the MCP tool list, the CLI and the ZAP
                IDL, read off a Manifest. Go's own three now go through them —
                the whole existing suite is the oracle — so Go keeps no private
                path for them and the other languages cannot rot.
  cmd/zipc      the build-time projector. It reads a manifest, or the fragments
                a compiler wrote, and writes the four documents.
  rust/         zip in Rust: #[zip::ops] on an impl block, #[derive(zip::Wire)]
                on a type, a router, a JSON binder, an HTTP door and a ZAP door.
                The wire is generated by zapgen from wire.zap; nothing here
                spells a byte offset by hand and nothing links Go.

The layout rule moved out of the manifest and into the projector, so the offsets
in a .zap are derived once for all three languages rather than three times;
TestProjectZAPMatchesRegistry pins that derivation against the layout the Go
encoder actually writes.

One service, written twice — corpus/info in Go and rust/examples/info in Rust,
fourteen ops each — is the gate. The OpenAPI document, the MCP tool list and the
CLI command tree come out byte-identical, and the ZAP schema agrees on every
struct, type and offset; it differs only where a field carries the declaring
language's own case convention and where the ledger names a type the way the
person who must change it would grep for it.
2026-09-15 11:41:56 -07:00

122 lines
4.2 KiB
Go

// SPDX-License-Identifier: BSD-3-Clause-Eco
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package zip_test
import (
"encoding/json"
"os"
"regexp"
"strings"
"testing"
"github.com/zap-proto/zip"
)
// One service, written twice.
//
// corpus/info declares fourteen operations in Go; rust/examples/info declares
// the same fourteen in Rust, natively — a proc-macro reads the signature, the
// route and the doc comment, and writes the manifest as the crate compiles.
// Both manifests then go through the SAME projector, and what comes out must be
// the same bytes.
//
// That is the whole claim, and it is mechanical: if these pass, zip is one
// framework with two implementations of its front end. If they fail, the two
// have drifted, and the diff says exactly where.
//
// The Rust side's documents are checked in (rust/examples/info/gen), the way a
// generated file is checked in anywhere else, so this test needs no cargo and
// no zipc — and regenerating them is what makes a change to the Rust source
// show up here.
func rustSide(t *testing.T, name string) []byte {
t.Helper()
b, err := os.ReadFile("rust/examples/info/gen/" + name)
if err != nil {
t.Skipf("the Rust corpus has not been projected: %v", err)
}
return b
}
func goSide(t *testing.T, v any) []byte {
t.Helper()
b, err := json.MarshalIndent(v, "", " ")
if err != nil {
t.Fatal(err)
}
return append(b, '\n')
}
func TestGoAndRustProjectOneOpenAPI(t *testing.T) {
want := goSide(t, corpus().OpenAPISpec())
if got := rustSide(t, "openapi.json"); string(got) != string(want) {
t.Errorf("the two front ends publish different documents\n--- go\n%s\n--- rust\n%s", want, got)
}
}
func TestGoAndRustProjectOneToolList(t *testing.T) {
want := goSide(t, corpus().MCPTools())
if got := rustSide(t, "mcp.json"); string(got) != string(want) {
t.Errorf("the two front ends publish different tools\n--- go\n%s\n--- rust\n%s", want, got)
}
}
func TestGoAndRustProjectOneCommandTree(t *testing.T) {
want := goSide(t, corpus().Commands())
if got := rustSide(t, "cli.json"); string(got) != string(want) {
t.Errorf("the two front ends publish different commands\n--- go\n%s\n--- rust\n%s", want, got)
}
}
// The ZAP schemas agree about the CONTRACT — the structs, their field types,
// their offsets and the interface — and differ in two places that are facts
// about the language and not about the operation: a field's identifier follows
// the source's own case convention, and the ledger names a type the way the
// person who must go and change it would grep for it.
//
// This measures that rather than asserting it. Folding the identifier case
// leaves only the ledger's comment lines, and if anything else ever differs the
// count goes up and the test says which line.
func TestGoAndRustProjectOneZAPContract(t *testing.T) {
want := zip.ZAPSchema("info", corpus()).String()
got := string(rustSide(t, "info.zap"))
a, b := fold(want), fold(got)
if len(a) != len(b) {
t.Fatalf("the two schemas are %d and %d lines", len(a), len(b))
}
for i := range a {
if a[i] == b[i] {
continue
}
if isLedger(a[i]) && isLedger(b[i]) {
continue // a source-language spelling, addressed to a reader
}
t.Errorf("line %d of the schema differs\n go : %s\n rust: %s", i+1, a[i], b[i])
}
}
// fold reads a schema into lines with every field identifier's case and
// underscores removed, which is the one difference a language's own naming
// convention makes to a contract both compile to the same accessors from.
func fold(s string) []string {
lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
for i, line := range lines {
m := fieldLine.FindStringSubmatch(line)
if m == nil {
continue
}
flat := strings.ToLower(strings.ReplaceAll(m[2], "_", ""))
lines[i] = m[1] + flat + " " + m[3] + m[4]
}
return lines
}
// fieldLine is one declared slot: an indent, a name, a type and an offset.
var fieldLine = regexp.MustCompile(`^(\s+)(\w+)\s+(\S+)(\s+@\d+)$`)
// isLedger reports a comment line, which is where the schema addresses a person
// rather than a compiler.
func isLedger(s string) bool { return strings.HasPrefix(strings.TrimSpace(s), "#") }