From ecd34b03bbab00828a873f93ee4c56fc67b2ce2e Mon Sep 17 00:00:00 2001 From: jamestexas <18285880+jamestexas@users.noreply.github.com> Date: Mon, 18 May 2026 13:27:52 -0600 Subject: [PATCH 1/3] [rosary-1b914d] feat(schema-bridge): lift from cloister (Apache-2.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The capnp→TS+zod codegen tool moves to notme where the schemas it consumes live. Re-licensed Apache-2.0 by sole author (no third-party contributions in cloister). NOTICE documents the lift. Wires into Taskfile.yml: `task gen:zod` regenerates committed TS zod files; `task gen:zod:check-drift` fails CI if they drift. Falsification: every lifted .rs file's content (license-line-stripped SHA-256) matches cloister's baseline byte-for-byte. Known follow-on (rosary-8d2c78): schema-bridge currently rejects the `$Go.package`/`$Go.import` annotation declarations imported via `/go.capnp`, so `task gen:zod` against notme/schema/identity.capnp errors with a clear `unmapped annotation` diagnostic. The lift itself is sound; the wiring works structurally; the annotation policy is a separate design call (see bead). Cloister-side deprecation of tools/schema-bridge/ is a separate bead. Co-Authored-By: Claude Opus 4.7 --- Taskfile.yml | 64 ++ packages/schema-bridge/.gitignore | 1 + packages/schema-bridge/Cargo.lock | 97 +++ packages/schema-bridge/Cargo.toml | 22 + packages/schema-bridge/NOTICE | 7 + packages/schema-bridge/README.md | 189 +++++ packages/schema-bridge/src/error.rs | 63 ++ packages/schema-bridge/src/inputs/capnp.rs | 373 ++++++++++ packages/schema-bridge/src/inputs/mod.rs | 5 + packages/schema-bridge/src/ir/mod.rs | 119 +++ packages/schema-bridge/src/lib.rs | 18 + packages/schema-bridge/src/main.rs | 97 +++ packages/schema-bridge/src/outputs/mod.rs | 5 + packages/schema-bridge/src/outputs/zod.rs | 237 ++++++ packages/schema-bridge/tests/integration.rs | 784 ++++++++++++++++++++ 15 files changed, 2081 insertions(+) create mode 100644 packages/schema-bridge/.gitignore create mode 100644 packages/schema-bridge/Cargo.lock create mode 100644 packages/schema-bridge/Cargo.toml create mode 100644 packages/schema-bridge/NOTICE create mode 100644 packages/schema-bridge/README.md create mode 100644 packages/schema-bridge/src/error.rs create mode 100644 packages/schema-bridge/src/inputs/capnp.rs create mode 100644 packages/schema-bridge/src/inputs/mod.rs create mode 100644 packages/schema-bridge/src/ir/mod.rs create mode 100644 packages/schema-bridge/src/lib.rs create mode 100644 packages/schema-bridge/src/main.rs create mode 100644 packages/schema-bridge/src/outputs/mod.rs create mode 100644 packages/schema-bridge/src/outputs/zod.rs create mode 100644 packages/schema-bridge/tests/integration.rs diff --git a/Taskfile.yml b/Taskfile.yml index 2689789..d3e7a0d 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -202,3 +202,67 @@ tasks: --keyring-append melange.rsa.pub \ --arch {{.ARCH}} - 'echo "Built packages/notme.tar — load with: docker load < packages/notme.tar"' + + # schema-bridge tasks + # + # capnp → zod TypeScript codegen via the Rust crate at + # packages/schema-bridge/. Replaces the older `schema:ts` TS-based + # codegen which silently emitted `z.unknown()` for unrecognised + # constructs; schema-bridge fails fast on any unmapped capnp shape. + # + # NOT wired into `task verify` yet — schema-bridge doesn't yet map + # the `$Go.package` / `$Go.import` annotations our identity.capnp + # uses for Go codegen. Filing as a follow-on bead. Until that lands, + # `task gen:zod` against identity.capnp will fail with a clear + # `unmapped capnp construct annotation` diagnostic. + schema-bridge:build: + desc: Build the capnpc-schema-bridge plugin (release profile) + dir: packages/schema-bridge + sources: + - src/**/*.rs + - Cargo.toml + - Cargo.lock + generates: + - target/release/capnpc-schema-bridge + cmds: + - cargo build --release + + gen:zod: + desc: Regenerate gen/ts/*.zod.ts from schema/*.capnp via schema-bridge + deps: [schema-bridge:build] + cmds: + # `capnp compile` exits 0 even when the plugin errors and writes + # nothing — its stderr carries the real diagnostic. Generate into + # a tmpdir + existence-check + atomic move. Mirrors the cloister + # cluster:zod pattern; same rationale. + - | + set -eu + TMPDIR=$(mktemp -d) + trap 'rm -rf "$TMPDIR"' EXIT + capnp compile \ + -o./packages/schema-bridge/target/release/capnpc-schema-bridge:"$TMPDIR" \ + -I "$HOME/go/pkg/mod/capnproto.org/go/capnp/v3@v3.1.0-alpha.2/std" \ + --src-prefix=schema schema/identity.capnp + if [ ! -f "$TMPDIR/identity.zod.ts" ]; then + echo "FAIL — schema-bridge produced no identity.zod.ts; check capnp stderr above" >&2 + exit 1 + fi + mv "$TMPDIR/identity.zod.ts" gen/ts/identity.zod.ts + + gen:zod:check-drift: + desc: Fail if committed gen/ts/*.zod.ts has drifted from a fresh regen + deps: [schema-bridge:build] + cmds: + - | + set -eu + TMPDIR=$(mktemp -d) + trap 'rm -rf "$TMPDIR"' EXIT + capnp compile \ + -o./packages/schema-bridge/target/release/capnpc-schema-bridge:"$TMPDIR" \ + -I "$HOME/go/pkg/mod/capnproto.org/go/capnp/v3@v3.1.0-alpha.2/std" \ + --src-prefix=schema schema/identity.capnp + if [ ! -f "$TMPDIR/identity.zod.ts" ]; then + echo "FAIL — schema-bridge produced no identity.zod.ts; check capnp stderr above" >&2 + exit 1 + fi + diff -u gen/ts/identity.zod.ts "$TMPDIR/identity.zod.ts" diff --git a/packages/schema-bridge/.gitignore b/packages/schema-bridge/.gitignore new file mode 100644 index 0000000..2f7896d --- /dev/null +++ b/packages/schema-bridge/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/packages/schema-bridge/Cargo.lock b/packages/schema-bridge/Cargo.lock new file mode 100644 index 0000000..3cf5e63 --- /dev/null +++ b/packages/schema-bridge/Cargo.lock @@ -0,0 +1,97 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "capnp" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e92edec8974fcd7ece90bb021db782abe14a61c10c817f197f700fef7430eb8" +dependencies = [ + "embedded-io", +] + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "schema-bridge" +version = "0.0.1" +dependencies = [ + "capnp", + "indoc", + "thiserror", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" diff --git a/packages/schema-bridge/Cargo.toml b/packages/schema-bridge/Cargo.toml new file mode 100644 index 0000000..567d350 --- /dev/null +++ b/packages/schema-bridge/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "schema-bridge" +version = "0.0.1" +edition = "2021" +description = "Capnp + JSON-extension schemas → zod / TS / JSON Schema. Fail-fast codegen." +license = "Apache-2.0" +publish = false + +[[bin]] +name = "capnpc-schema-bridge" +path = "src/main.rs" + +[lib] +name = "schema_bridge" +path = "src/lib.rs" + +[dependencies] +capnp = "0.21" +thiserror = "2.0" + +[dev-dependencies] +indoc = "2.0" diff --git a/packages/schema-bridge/NOTICE b/packages/schema-bridge/NOTICE new file mode 100644 index 0000000..4d8aa9c --- /dev/null +++ b/packages/schema-bridge/NOTICE @@ -0,0 +1,7 @@ +packages/schema-bridge — Apache-2.0 + +Lifted from cloister/tools/schema-bridge/ (AGPL-3.0) on 2026-05-18 and +re-licensed under Apache-2.0 by the sole author. No third-party +contributions existed in the cloister copy (verified via git log). + +See vault/NOTICE for the precedent of this round-trip pattern. diff --git a/packages/schema-bridge/README.md b/packages/schema-bridge/README.md new file mode 100644 index 0000000..0f8fb06 --- /dev/null +++ b/packages/schema-bridge/README.md @@ -0,0 +1,189 @@ +# schema-bridge + +Capnp + JSON-extension schemas → zod / TS / (future: JSON Schema). +Single source of truth, fail-fast codegen, designed to be extracted to +its own crate once it stabilises. + +## Why + +cloister had two parallel schema pipelines: capnp→TS for the manifest, +zod→JSON Schema for tool I/O. Adding a third source (the `.cloister.json` +CLI config) would have meant hand-mirroring a capnp struct against a +zod schema, with ADR-0004's append-only / monotonic-ordinal guarantees +dropped on the floor. Bad shape, deferred forever. + +schema-bridge is the missing piece: read a capnp schema, lower into a +small intermediate representation (IR), emit every downstream target +from that IR. capnp's own ordinal rules carry through; new fields land +in one place; nothing drifts. + +## Self-maintenance invariant + +The point of this tool is that it stays correct without anyone +remembering to update it. The mechanism: **any capnp construct without +a complete IR-and-emit mapping is a hard error.** + +```text +unmapped capnp construct `list` at node id=aaaa (Foo.items): + add a mapping for `list` in schema-bridge, or open an issue +``` + +This means the codegen is *intentionally incomplete*, but every gap is +loud. notme's older `capnp-to-ts.ts` (which this tool replaces in +spirit) silently emitted `z.unknown()` for unrecognised constructs; +that's the precise failure mode schema-bridge exists to prevent. + +**Today the codegen is opt-in** — `task cluster:zod` regenerates +`src/generated/cluster.zod.ts` and `task cluster:zod:check-drift` +verifies the committed copy matches. Neither task is wired into +`task lint` or `task verify` yet, so an unmapped capnp construct +won't break CI automatically; it WILL break the moment a developer +runs the regen or drift-check task locally. The plan is to wire +`cluster:zod:check-drift` into `task verify` once the schema-bridge +mapping coverage stabilises (tracked separately) — at that point +unmapped constructs become a hard CI failure. No silent fallbacks +regardless. + +## What's mapped today + +| capnp construct | IR | zod emit | +|----------------------------------------|-----------------------------|-------------------------------------------------| +| `struct` | `Struct { fields, union }` | `z.lazy(() => z.object({…}))` | +| scalar fields | `Scalar(_)` | `z.string()` / `z.number()` / etc. | +| struct refs | `StructRef(name)` | `{Name}Schema` | +| enum refs | `EnumRef(name)` | `{Name}Schema` (where `{Name}Schema = z.enum`) | +| `List(T)` | `List(Box)` | `z.array(T)` (recurses) | +| top-level `enum` | `Enum { name, variants }` | `z.enum([…])` + `type X = "a" \| "b"` | +| `name :union { … }` (group form) | `Struct.union: Some(Union)` | `z.union([z.object({ : }).strict(), …])` — one strict single-key object per variant | +| Void union variants | `UnionVariant.ty = Void` | `z.object({ : z.null() }).strict()` inside the union | +| union-only structs (no base fields) | empty `fields`, `Some(union)` | same `z.union([…])` shape (no intersect wrapper) | + +Verified end-to-end (run `capnp compile -oschema-bridge:` against +each): + +- `manifest/cluster.capnp` → 136 lines clean zod TS (1 enum, 2 named + unions including all-Void `Wire.transport`) +- `manifest/cloister.capnp` → 246 lines clean zod TS (13 structs, + `Backend.kind` 6-variant union, `Route.kind` 10-variant mostly-Void + union) + +| Deliberately unmapped (errors today)| reason | +|-------------------------------------|----------------------------------------------| +| `interface` | RPC types — out of scope for now | +| `const`, `annotation` (top-level) | not used at the schema surfaces we care about | +| `anyPointer` | typed-erasure escape hatch; unmapped | +| generics (`$Foo(T)`) | needs IR generics representation | +| anonymous inline union | unused in cloister; the `name :union {…}` sugar covers all current use| +| non-union group (field namespacing) | unused in cloister | +| group variant inside a union | legal capnp, unused in cloister | +| any annotation on a node/field | including `$Json.flatten`, `$Json.discriminator`, `$Json.name`, `$Json.base64`, `$Json.hex`, `$Json.notification` (ids from `capnp/compat/json.capnp`) — affect JSON encoding and so MUST be handled or fail loudly; cloister capnp files use no annotations today | + +Adding any of these is a focused change: extend the IR variant, add +the emit in `outputs/zod.rs`, add one golden test + leave one +fail-case test for the still-unmapped neighbour. The fail-case tests +stay forever as regression guards — they catch a future construct +that silently slips through because it looks "close enough" to +something that IS supported. + +## Visibility of known gaps + +Every unmapped construct above is paired with two tests: + +1. **A regression-guard fail-fast test** — must throw + `UnmappedConstruct`. Stays active forever; catches a future + construct that silently slips through. +2. **An `#[ignore]`'d aspirational stub** (where the emit shape is + already clear) — documents what success will look like. `cargo + test` prints ` ... ignored, schema-bridge does not yet …` + on every run, so the gap is visible in CI output without breaking + the build. Activation gesture: remove `#[ignore]`, implement, fill + in the assertions. The paired regression-guard stays. + +Today's `#[ignore]`'d stubs (search for them in +`tests/integration.rs`): + +- `flat_union_emit_under_json_flatten` — emit when `$Json.flatten` + is on a union field +- `anonymous_inline_union_emits_flat` — emit for + `struct Foo { union { … } }` +- `non_union_group_emits_nested_object` — emit for + `field :group { x; y; }` (field namespacing without discriminator) + +Constructs without aspirational stubs (`interface`, generics, +`anyPointer`) are deferred indefinitely — they're non-goals for the +zod-validation surface today, not just "not yet." + +## How it runs + +```sh +# As a capnp plugin (the supported invocation): +capnp compile \ + -o./target/release/capnpc-schema-bridge:./gen \ + manifest/cli-config.capnp +``` + +`capnp compile` invokes the binary with the parsed `CodeGeneratorRequest` +on stdin. The binary writes `/.zod.ts` +(e.g. `cluster.zod.ts` from `manifest/cluster.capnp`) — zod schemas +plus TS interface declarations in one file. One emit per invocation +today; per-file splitting is on the follow-on list. + +For development the library is also drivable directly — see +`tests/integration.rs` for examples of building a `CodeGeneratorRequest` +by hand. That's how the test suite stays hermetic (no capnp CLI +needed in CI). + +## Layout + +``` +tools/schema-bridge/ +├── Cargo.toml standalone workspace; depends only on capnp + thiserror +├── README.md this file +├── src/ +│ ├── lib.rs public API for tests +│ ├── main.rs capnp plugin entry — stdin → emit → file +│ ├── error.rs SchemaBridgeError + UnmappedConstruct +│ ├── ir/ the intermediate representation +│ ├── inputs/ capnp → IR (future: json-extension/ for aggregation) +│ └── outputs/ IR → zod (future: ts.rs, json_schema.rs) +└── tests/ + └── integration.rs golden + fail-case suite +``` + +## Follow-on work + +Tracked separately from this initial drop. In rough priority order: + +1. Wire into `task manifest` + `task verify` — codegen step alongside + the existing capnp→TS pipeline. Decide whether the output replaces + `src/generated/cluster.ts` or sits beside it as + `src/generated/cluster.zod.ts`. +2. JSON-extension input adapter for the aggregation pattern (capnp + defines the structural backbone, JSON files supply per-variant + field extensions). Where the polymorphism for skill / mcp / agent + actually lands. +3. JSON Schema output adapter (`outputs/json_schema.rs`) — drives the + `$schema` field in `.cloister.json` for editor autocomplete. +4. TS-types-only output adapter, separated from the zod emit, so + consumers can pick one or both. +5. End-to-end fixture tests against `manifest/*.capnp` — currently + verified manually (see README "What's mapped today"); locking that + in as a golden-output test in CI prevents silent regressions. +6. License — deferred per the implementation conversation. Default + matches cloister (AGPL-3.0-or-later); revisit if extraction to a + standalone repo lands. + +## Non-goals (the helm comparison) + +The aggregation pattern this tool serves looks superficially like +helm — multiple inputs composing into one output — but the design +explicitly avoids helm's failure modes: + +- ❌ No string templating (no `{{ … }}` substitution anywhere) +- ❌ No runtime value substitution +- ❌ No values.yaml-style override layers chained 4-deep +- ✅ All aggregation is at the IR level, statically resolved +- ✅ Output is plain emitted source code, reviewable and diffable + +If a feature looks like it might pull this toward helm-shaped +templating, reject the feature. diff --git a/packages/schema-bridge/src/error.rs b/packages/schema-bridge/src/error.rs new file mode 100644 index 0000000..add8698 --- /dev/null +++ b/packages/schema-bridge/src/error.rs @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 notme contributors +// Origin: lifted from cloister (AGPL-3.0) by sole author, contributed to notme under Apache-2.0 on 2026-05-18; see NOTICE. + +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum SchemaBridgeError { + // Fail-fast on any capnp construct we haven't taught the IR to + // represent yet. CI surfaces this as a build break, forcing the + // codegen to grow a mapping rather than silently emitting + // `z.unknown()`. See README §"Self-maintenance invariant". + #[error("unmapped capnp construct `{kind}` at {location}: {hint}")] + UnmappedConstruct { + kind: String, + location: String, + hint: String, + }, + + // A field references a struct/enum we never saw in this schema. + // Usually means an `import` we didn't follow or a typo. + #[error("unresolved type reference `{name}` at {location}")] + UnresolvedReference { name: String, location: String }, + + // Two input sources defined the same symbol with incompatible + // shapes. Reserved for the aggregation path; not reachable yet. + #[error("aggregation conflict on `{symbol}`: {detail}")] + AggregationConflict { symbol: String, detail: String }, + + #[error("capnp parse error: {0}")] + Capnp(#[from] capnp::Error), + + // `which()` returns this when the discriminant on a capnp union is + // outside the known variant set — i.e. the schema-bridge build was + // linked against a different capnp std than the input. Treat as a + // schema-shape failure rather than a missing mapping. + #[error("capnp discriminant out of range: {0}")] + NotInSchema(#[from] capnp::NotInSchema), + + #[error("capnp utf-8 error: {0}")] + Utf8(#[from] std::str::Utf8Error), + + #[error("capnp schema not in expected shape: {0}")] + SchemaShape(String), + + #[error("io error: {0}")] + Io(#[from] std::io::Error), +} + +impl SchemaBridgeError { + pub fn unmapped(kind: impl Into, location: impl Into) -> Self { + let kind = kind.into(); + Self::UnmappedConstruct { + hint: format!( + "add a mapping for `{kind}` in schema-bridge, or open an issue" + ), + kind, + location: location.into(), + } + } +} + +pub type Result = std::result::Result; diff --git a/packages/schema-bridge/src/inputs/capnp.rs b/packages/schema-bridge/src/inputs/capnp.rs new file mode 100644 index 0000000..70d1880 --- /dev/null +++ b/packages/schema-bridge/src/inputs/capnp.rs @@ -0,0 +1,373 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 notme contributors +// Origin: lifted from cloister (AGPL-3.0) by sole author, contributed to notme under Apache-2.0 on 2026-05-18; see NOTICE. + +// Capnp → IR. +// +// Reads a `CodeGeneratorRequest` (as produced by `capnp compile -o`) +// and lowers the subset we currently understand into IR. Anything we +// don't recognize becomes `SchemaBridgeError::UnmappedConstruct` — +// loud, immediate, build-breaking. See README §"Self-maintenance +// invariant". + +use std::collections::HashMap; + +use ::capnp::schema_capnp; + +use crate::error::{Result, SchemaBridgeError}; +use crate::ir::{Enum, FieldType, ScalarType, Schema, Struct, StructField, Union, UnionVariant}; + +// Sentinel capnp uses for a field that is NOT part of a discriminated +// union. Capnp ABI: `Field.discriminantValue` is `0xffff` (== max u16) +// for non-union fields. +const NO_DISCRIMINANT: u16 = 0xffff; + +// Annotation ids from capnp/compat/json.capnp (`@0x8ef99297a43a5e34`). +// These affect JSON encoding and so MUST either be honoured or fail +// loudly — silently ignoring `$flatten` would silently produce a zod +// schema that rejects the JSON capnp actually emits. None of cloister's +// capnp files use annotations today; if any get added, schema-bridge +// stops on this list and forces a decision (handle or remove). +const ANN_JSON_FLATTEN: u64 = 0x82d3e852af0336bf; +const ANN_JSON_DISCRIMINATOR: u64 = 0xcfa794e8d19a0162; +const ANN_JSON_NAME: u64 = 0xfa5b1fd61c2e7c3d; +const ANN_JSON_BASE64: u64 = 0xd7d879450a253e4b; +const ANN_JSON_HEX: u64 = 0xf061e22f0ae5c7b5; +const ANN_JSON_NOTIFICATION: u64 = 0xa0a054dea32fd98c; + +fn annotation_kind(id: u64) -> String { + match id { + ANN_JSON_FLATTEN => "annotation `$Json.flatten`".to_owned(), + ANN_JSON_DISCRIMINATOR => "annotation `$Json.discriminator`".to_owned(), + ANN_JSON_NAME => "annotation `$Json.name`".to_owned(), + ANN_JSON_BASE64 => "annotation `$Json.base64`".to_owned(), + ANN_JSON_HEX => "annotation `$Json.hex`".to_owned(), + ANN_JSON_NOTIFICATION => "annotation `$Json.notification`".to_owned(), + other => format!("annotation @{other:#x}"), + } +} + +fn check_annotations( + annotations: capnp::struct_list::Reader<'_, schema_capnp::annotation::Owned>, + location: &str, +) -> Result<()> { + if !annotations.is_empty() { + let kind = annotation_kind(annotations.get(0).get_id()); + return Err(SchemaBridgeError::unmapped(kind, location)); + } + Ok(()) +} + +pub fn parse( + request: schema_capnp::code_generator_request::Reader<'_>, +) -> Result { + let nodes = request.get_nodes()?; + + // Pass 1: catalog every named-type node id → short name AND keep a + // Reader handle for each node so group resolution can hop from + // field.typeId back to the group's anonymous struct without + // re-scanning the whole list. Capnp Readers are zero-cost views + // into the message arena, so storing them in a HashMap is fine. + let mut struct_names: HashMap = HashMap::new(); + let mut enum_names: HashMap = HashMap::new(); + let mut node_by_id: HashMap> = HashMap::new(); + for node in nodes.iter() { + node_by_id.insert(node.get_id(), node); + match node.which()? { + schema_capnp::node::Which::Struct(_) => { + // Group nodes have isGroup=true; only catalog real + // top-level struct names. Anonymous group structs + // have empty short names anyway, but skipping them + // here keeps `struct_names` clean for ref resolution. + let n = short_name(node)?; + if !n.is_empty() { + struct_names.insert(node.get_id(), n); + } + } + schema_capnp::node::Which::Enum(_) => { + enum_names.insert(node.get_id(), short_name(node)?); + } + _ => {} + } + } + + // Pass 2: emit IR. Non-struct/non-enum top-level nodes are + // tolerated only for `file` (the schema's own container); + // anything else is an unmapped construct. Anonymous group nodes + // (isGroup=true) are skipped at the top level because they're + // owned by their parent struct, not first-class IR entities. + let mut schema = Schema::new(); + for node in nodes.iter() { + let location = format!("node id={:x}", node.get_id()); + match node.which()? { + schema_capnp::node::Which::File(_) => continue, + schema_capnp::node::Which::Struct(s) => { + if s.get_is_group() { + continue; + } + check_annotations(node.get_annotations()?, &location)?; + schema.structs.push(parse_struct( + node, + s, + &struct_names, + &enum_names, + &node_by_id, + &location, + )?); + } + schema_capnp::node::Which::Enum(e) => { + check_annotations(node.get_annotations()?, &location)?; + schema.enums.push(parse_enum(node, e)?); + } + schema_capnp::node::Which::Interface(_) => { + return Err(SchemaBridgeError::unmapped("interface", location)); + } + schema_capnp::node::Which::Const(_) => { + return Err(SchemaBridgeError::unmapped("const", location)); + } + schema_capnp::node::Which::Annotation(_) => { + return Err(SchemaBridgeError::unmapped("annotation", location)); + } + } + } + + Ok(schema) +} + +fn parse_enum( + node: schema_capnp::node::Reader<'_>, + e: schema_capnp::node::enum_::Reader<'_>, +) -> Result { + let name = short_name(node)?; + let mut variants = Vec::new(); + for enumerant in e.get_enumerants()?.iter() { + let v = enumerant.get_name()?.to_str()?.to_owned(); + check_annotations( + enumerant.get_annotations()?, + &format!("enum {name}.{v}"), + )?; + variants.push(v); + } + Ok(Enum { name, variants }) +} + +fn parse_struct<'a>( + node: schema_capnp::node::Reader<'a>, + s: schema_capnp::node::struct_::Reader<'a>, + struct_names: &HashMap, + enum_names: &HashMap, + node_by_id: &HashMap>, + location: &str, +) -> Result { + let name = short_name(node)?; + + // Direct anonymous union on the parent struct (`struct Foo { + // union { … } }`) is a real capnp form but doesn't appear in + // cloister today. Keep the error so it's visible the day someone + // writes it. + if s.get_discriminant_count() > 0 { + return Err(SchemaBridgeError::unmapped( + "anonymous inline union (use `name :union { … }` instead)", + format!("{location} ({name})"), + )); + } + + let mut fields = Vec::new(); + let mut union: Option = None; + + for field in s.get_fields()?.iter() { + let field_name = field.get_name()?.to_str()?.to_owned(); + let ordinal = field.get_code_order(); + let field_location = format!("{location} ({name}.{field_name})"); + + check_annotations(field.get_annotations()?, &field_location)?; + + match field.which()? { + schema_capnp::field::Which::Slot(slot) => { + let ty = field_type(slot.get_type()?, struct_names, enum_names, &field_location)?; + fields.push(StructField { + name: field_name, + ordinal, + ty, + }); + } + schema_capnp::field::Which::Group(g) => { + // A group field points at an anonymous struct node. + // We only support the case where that node carries a + // union (the `name :union { … }` sugar). Non-union + // groups (plain field-namespacing groups) need a + // separate emit shape and aren't used in cloister. + let group_id = g.get_type_id(); + let group_node = node_by_id.get(&group_id).ok_or_else(|| { + SchemaBridgeError::UnresolvedReference { + name: format!("group node id={group_id:x}"), + location: field_location.clone(), + } + })?; + let group_struct = match group_node.which()? { + schema_capnp::node::Which::Struct(gs) => gs, + _ => { + return Err(SchemaBridgeError::SchemaShape(format!( + "group field {field_location} references non-struct node" + ))); + } + }; + if group_struct.get_discriminant_count() == 0 { + return Err(SchemaBridgeError::unmapped( + "non-union group", + field_location, + )); + } + if union.is_some() { + return Err(SchemaBridgeError::SchemaShape(format!( + "struct {name} has more than one union group; \ + capnp permits only one union per struct" + ))); + } + union = Some(parse_union( + &field_name, + group_struct, + struct_names, + enum_names, + node_by_id, + &field_location, + )?); + } + } + } + + Ok(Struct { + name, + fields, + union, + }) +} + +fn parse_union<'a>( + discriminant_name: &str, + group: schema_capnp::node::struct_::Reader<'a>, + struct_names: &HashMap, + enum_names: &HashMap, + node_by_id: &HashMap>, + location: &str, +) -> Result { + let mut variants = Vec::new(); + for field in group.get_fields()?.iter() { + let variant_name = field.get_name()?.to_str()?.to_owned(); + let variant_location = format!("{location}.{variant_name}"); + + check_annotations(field.get_annotations()?, &variant_location)?; + + // Defensive: union variants always carry a discriminant value + // (and non-variant fields shouldn't appear inside a union + // group). If something with NO_DISCRIMINANT lands here, it's + // a schema shape we don't understand. + if field.get_discriminant_value() == NO_DISCRIMINANT { + return Err(SchemaBridgeError::SchemaShape(format!( + "field {variant_location} inside a union group has no \ + discriminant value" + ))); + } + + match field.which()? { + schema_capnp::field::Which::Slot(slot) => { + let ty = field_type( + slot.get_type()?, + struct_names, + enum_names, + &variant_location, + )?; + variants.push(UnionVariant { + name: variant_name, + ty, + }); + } + schema_capnp::field::Which::Group(_) => { + // A union variant that is itself a group (sub-struct + // of fields). Capnp permits this; we don't yet emit + // it. Loud failure rather than silent. + let _ = node_by_id; // (lookup deliberately unused here) + return Err(SchemaBridgeError::unmapped( + "group variant inside union", + variant_location, + )); + } + } + } + + Ok(Union { + discriminant_name: discriminant_name.to_owned(), + variants, + }) +} + +fn field_type( + ty: schema_capnp::type_::Reader<'_>, + struct_names: &HashMap, + enum_names: &HashMap, + location: &str, +) -> Result { + use schema_capnp::type_::Which as TW; + let which = ty.which()?; + Ok(match which { + TW::Void(()) => FieldType::Scalar(ScalarType::Void), + TW::Bool(()) => FieldType::Scalar(ScalarType::Bool), + TW::Int8(()) => FieldType::Scalar(ScalarType::Int8), + TW::Int16(()) => FieldType::Scalar(ScalarType::Int16), + TW::Int32(()) => FieldType::Scalar(ScalarType::Int32), + TW::Int64(()) => FieldType::Scalar(ScalarType::Int64), + TW::Uint8(()) => FieldType::Scalar(ScalarType::UInt8), + TW::Uint16(()) => FieldType::Scalar(ScalarType::UInt16), + TW::Uint32(()) => FieldType::Scalar(ScalarType::UInt32), + TW::Uint64(()) => FieldType::Scalar(ScalarType::UInt64), + TW::Float32(()) => FieldType::Scalar(ScalarType::Float32), + TW::Float64(()) => FieldType::Scalar(ScalarType::Float64), + TW::Text(()) => FieldType::Scalar(ScalarType::Text), + TW::Data(()) => FieldType::Scalar(ScalarType::Data), + TW::Struct(s) => { + let id = s.get_type_id(); + let name = struct_names.get(&id).ok_or_else(|| { + SchemaBridgeError::UnresolvedReference { + name: format!("struct id={id:x}"), + location: location.to_owned(), + } + })?; + FieldType::StructRef(name.clone()) + } + TW::List(list) => { + let elem = field_type(list.get_element_type()?, struct_names, enum_names, location)?; + FieldType::List(Box::new(elem)) + } + TW::Enum(e) => { + let id = e.get_type_id(); + let name = enum_names.get(&id).ok_or_else(|| { + SchemaBridgeError::UnresolvedReference { + name: format!("enum id={id:x}"), + location: location.to_owned(), + } + })?; + FieldType::EnumRef(name.clone()) + } + TW::Interface(_) => { + return Err(SchemaBridgeError::unmapped("interface (type ref)", location)); + } + TW::AnyPointer(_) => { + return Err(SchemaBridgeError::unmapped("anyPointer", location)); + } + }) +} + +// Extract the unqualified name from a capnp node. `display_name` is the +// fully-qualified form like `"manifest/cli-config.capnp:EnabledItem"`; +// `display_name_prefix_length` marks where the filename ends. +fn short_name(node: schema_capnp::node::Reader<'_>) -> Result { + let display = node.get_display_name()?.to_str()?; + let prefix = node.get_display_name_prefix_length() as usize; + if prefix > display.len() { + return Err(SchemaBridgeError::SchemaShape(format!( + "display_name_prefix_length {prefix} exceeds display_name length {}", + display.len() + ))); + } + Ok(display[prefix..].to_owned()) +} diff --git a/packages/schema-bridge/src/inputs/mod.rs b/packages/schema-bridge/src/inputs/mod.rs new file mode 100644 index 0000000..5f46853 --- /dev/null +++ b/packages/schema-bridge/src/inputs/mod.rs @@ -0,0 +1,5 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 notme contributors +// Origin: lifted from cloister (AGPL-3.0) by sole author, contributed to notme under Apache-2.0 on 2026-05-18; see NOTICE. + +pub mod capnp; diff --git a/packages/schema-bridge/src/ir/mod.rs b/packages/schema-bridge/src/ir/mod.rs new file mode 100644 index 0000000..1bdec34 --- /dev/null +++ b/packages/schema-bridge/src/ir/mod.rs @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 notme contributors +// Origin: lifted from cloister (AGPL-3.0) by sole author, contributed to notme under Apache-2.0 on 2026-05-18; see NOTICE. + +// Intermediate representation. +// +// Inputs (capnp, JSON extensions, future formats) lower into this +// type-set. Outputs (zod, TS types, JSON Schema) read from it. New +// constructs land here first; an input that produces an IR node no +// output understands becomes a compile error, an output that asks for +// an IR variant no input emits is dead code that the compiler flags. +// +// V1 scope is deliberately narrow: structs of named fields, scalar +// or struct-ref typed. Enums, unions, lists, groups, generics, +// anyPointer — all `UnmappedConstruct` for now. See error.rs. + +#[derive(Debug, Clone, PartialEq)] +pub struct Schema { + pub enums: Vec, + pub structs: Vec, +} + +impl Schema { + pub fn new() -> Self { + Self { + enums: Vec::new(), + structs: Vec::new(), + } + } + + pub fn find_struct(&self, name: &str) -> Option<&Struct> { + self.structs.iter().find(|s| s.name == name) + } + + pub fn find_enum(&self, name: &str) -> Option<&Enum> { + self.enums.iter().find(|e| e.name == name) + } +} + +impl Default for Schema { + fn default() -> Self { + Self::new() + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Enum { + pub name: String, + // Position-stable: enumerants[i] has capnp ordinal i. Wire-format + // safety is the user's job (ADR-0004's monotonic-ordinal rule); + // schema-bridge just preserves what capnp gave it. + pub variants: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Struct { + pub name: String, + // Always-present fields. Capnp lets a struct carry both base + // fields and a union; both forms (`struct Foo { x @0 :Text; + // kind :union { … } }`) map naturally. + pub fields: Vec, + pub union: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Union { + // The discriminant in capnp is positional, not named — `name + // :union { … }` is sugar for `name :group { union { … } }`. We + // surface the group's name as the discriminant key so the + // emitted zod/TS reads like `kind: "durableObject"` rather than + // a synthesised `_tag`. + pub discriminant_name: String, + pub variants: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct UnionVariant { + pub name: String, + // Capnp permits `someVariant @N :Void` for tag-only variants + // (no payload). Those represent here as `Scalar(Void)` and the + // zod emitter knows not to include a sibling property for them. + pub ty: FieldType, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct StructField { + pub name: String, + pub ordinal: u16, + pub ty: FieldType, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum FieldType { + Scalar(ScalarType), + StructRef(String), + EnumRef(String), + // `List(List(Text))` is legal capnp; the box keeps the recursion + // representable without making FieldType itself recursive at the + // type level. + List(Box), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ScalarType { + Void, + Bool, + Int8, + Int16, + Int32, + Int64, + UInt8, + UInt16, + UInt32, + UInt64, + Float32, + Float64, + Text, + Data, +} diff --git a/packages/schema-bridge/src/lib.rs b/packages/schema-bridge/src/lib.rs new file mode 100644 index 0000000..df25898 --- /dev/null +++ b/packages/schema-bridge/src/lib.rs @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 notme contributors +// Origin: lifted from cloister (AGPL-3.0) by sole author, contributed to notme under Apache-2.0 on 2026-05-18; see NOTICE. + +// Public library surface for schema-bridge. +// +// The binary at src/main.rs is a thin shim over this library. Tests +// drive the library directly with hand-built inputs so that golden + +// fail-case coverage doesn't depend on having the `capnp` CLI +// installed. + +pub mod error; +pub mod ir; +pub mod inputs; +pub mod outputs; + +pub use error::SchemaBridgeError; +pub use ir::{Enum, FieldType, ScalarType, Schema, Struct, StructField, Union, UnionVariant}; diff --git a/packages/schema-bridge/src/main.rs b/packages/schema-bridge/src/main.rs new file mode 100644 index 0000000..e3f47ca --- /dev/null +++ b/packages/schema-bridge/src/main.rs @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 notme contributors +// Origin: lifted from cloister (AGPL-3.0) by sole author, contributed to notme under Apache-2.0 on 2026-05-18; see NOTICE. + +// capnpc-schema-bridge — capnp compiler plugin. +// +// Invoked by `capnp compile -oschema-bridge: `. +// Reads a `CodeGeneratorRequest` from stdin, lowers to IR via +// inputs::capnp, emits zod TS via outputs::zod, writes one .ts file +// per requested capnp source. +// +// All real logic lives in the library at src/lib.rs so that tests can +// drive it directly without needing the `capnp` CLI installed. + +use std::io::{self, Write}; +use std::path::PathBuf; +use std::process::ExitCode; + +use capnp::schema_capnp; +use capnp::serialize; + +use schema_bridge::error::SchemaBridgeError; +use schema_bridge::{inputs, outputs}; + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + // Plugin errors must go to stderr — stdout is reserved for + // the response capnp message, even though our v1 plugin + // doesn't emit one. + eprintln!("schema-bridge: {e}"); + // Print the chain too, since `SchemaBridgeError::Capnp(_)` + // can wrap deeper detail. + let mut source = std::error::Error::source(&e); + while let Some(s) = source { + eprintln!(" caused by: {s}"); + source = s.source(); + } + ExitCode::FAILURE + } + } +} + +fn run() -> Result<(), SchemaBridgeError> { + let out_dir = parse_out_dir(); + + let mut stdin = io::stdin().lock(); + let message = serialize::read_message(&mut stdin, capnp::message::ReaderOptions::new())?; + let request = message.get_root::()?; + + // Derive the output filename from the first requested file in the + // CodeGeneratorRequest. `capnp compile -oschema-bridge:dir + // manifest/cluster.capnp` puts `manifest/cluster.capnp` as the + // first requested file's name → output is `/cluster.zod.ts`. + let out_name = derive_out_name(request)?; + + let schema = inputs::capnp::parse(request)?; + let emitted = outputs::zod::emit(&schema)?; + + let out_path = out_dir.join(&out_name); + if let Some(parent) = out_path.parent() { + std::fs::create_dir_all(parent)?; + } + let mut f = std::fs::File::create(&out_path)?; + f.write_all(emitted.as_bytes())?; + + Ok(()) +} + +fn derive_out_name( + request: schema_capnp::code_generator_request::Reader<'_>, +) -> Result { + let requested = request.get_requested_files()?; + if requested.is_empty() { + // Fallback for hand-driven invocations that don't set a + // requested file (e.g. ad-hoc fixtures during debugging). + return Ok("schema.zod.ts".to_owned()); + } + let filename = requested.get(0).get_filename()?.to_str()?; + // basename without the `.capnp` extension + let basename = std::path::Path::new(filename) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("schema"); + Ok(format!("{basename}.zod.ts")) +} + +// Capnp passes the plugin's output directory as the first argv entry +// when invoked as `-o:`. Fall back to CWD when run +// manually for debugging. +fn parse_out_dir() -> PathBuf { + std::env::args() + .nth(1) + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".")) +} diff --git a/packages/schema-bridge/src/outputs/mod.rs b/packages/schema-bridge/src/outputs/mod.rs new file mode 100644 index 0000000..e606394 --- /dev/null +++ b/packages/schema-bridge/src/outputs/mod.rs @@ -0,0 +1,5 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 notme contributors +// Origin: lifted from cloister (AGPL-3.0) by sole author, contributed to notme under Apache-2.0 on 2026-05-18; see NOTICE. + +pub mod zod; diff --git a/packages/schema-bridge/src/outputs/zod.rs b/packages/schema-bridge/src/outputs/zod.rs new file mode 100644 index 0000000..9c7205d --- /dev/null +++ b/packages/schema-bridge/src/outputs/zod.rs @@ -0,0 +1,237 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 notme contributors +// Origin: lifted from cloister (AGPL-3.0) by sole author, contributed to notme under Apache-2.0 on 2026-05-18; see NOTICE. + +// IR → zod TypeScript source. +// +// Emits one zod schema per struct plus a TypeScript type alias derived +// from it. Struct-to-struct references resolve by name; topological +// order is enforced via forward-declaration via `z.lazy(...)`. + +use std::fmt::Write as _; + +use crate::error::Result; +use crate::ir::{Enum, FieldType, ScalarType, Schema, Struct, StructField, Union}; + +pub fn emit(schema: &Schema) -> Result { + let mut out = String::new(); + writeln!(out, "// Generated by schema-bridge — do not edit by hand.").unwrap(); + writeln!( + out, + "// Regenerate with `task cluster:zod` (runs the schema-bridge plugin)." + ) + .unwrap(); + writeln!(out, "//").unwrap(); + writeln!(out, "// If schema-bridge errored on a construct used here,").unwrap(); + writeln!(out, "// the codegen needs a new mapping — extend it, don't").unwrap(); + writeln!(out, "// hand-author this file.").unwrap(); + writeln!(out).unwrap(); + writeln!(out, r#"import {{ z }} from "zod";"#).unwrap(); + writeln!(out).unwrap(); + + // Enums emit first: no forward references possible (they only + // reference string literals), and struct field renderers may + // reference enum schema names. + for e in &schema.enums { + emit_enum(&mut out, e); + writeln!(out).unwrap(); + } + + for s in &schema.structs { + emit_struct(&mut out, s)?; + writeln!(out).unwrap(); + } + + Ok(out) +} + +fn emit_enum(out: &mut String, e: &Enum) { + let variants_zod: Vec = + e.variants.iter().map(|v| format!("\"{v}\"")).collect(); + let variants_ts: Vec = + e.variants.iter().map(|v| format!("\"{v}\"")).collect(); + writeln!( + out, + "export const {name}Schema = z.enum([{joined}]);", + name = e.name, + joined = variants_zod.join(", ") + ) + .unwrap(); + writeln!(out, "export type {name} = {joined};", name = e.name, joined = variants_ts.join(" | ")) + .unwrap(); +} + +fn emit_struct(out: &mut String, s: &Struct) -> Result<()> { + // `z.lazy` lets struct refs forward-declare without a topological + // sort. The cost is one extra closure per schema; the saving is + // not having to detect cycles. + writeln!( + out, + "export const {name}Schema: z.ZodType<{name}> = z.lazy(() =>", + name = s.name + ) + .unwrap(); + + match (&s.fields[..], &s.union) { + // Plain struct: no union. + (fields, None) => { + emit_zod_object(out, " ", fields, None); + writeln!(out, ");").unwrap(); + } + // Struct with a union — the union is a NESTED object under + // its discriminant name, matching capnp's JSON convention + // (`"kind": { "external": {…} }` for struct variants, + // `"transport": { "uds": null }` for Void variants). Base + // fields are siblings of that nested object. + (fields, Some(u)) => { + emit_zod_object(out, " ", fields, Some(u)); + writeln!(out, ");").unwrap(); + } + } + writeln!(out).unwrap(); + + emit_ts_type(out, s); + + Ok(()) +} + +// `.strict()` rejects unknown keys at parse time — without it, zod's +// default behavior silently drops them, so an operator typo like +// `holdsCredentials = ["SECRET"]` (extra 's' vs the schema's +// `holdsCredential`) discards the credential without a diagnostic. +// Per cloister-cf2e6a / skeptic N1: the schema-bridge is the boundary +// where typos must become errors. Inner union variants ALSO use +// `.strict()` (see `emit_zod_union` below). +fn emit_zod_object(out: &mut String, indent: &str, fields: &[StructField], union: Option<&Union>) { + writeln!(out, "{indent}z.object({{").unwrap(); + for field in fields { + let rendered = render_field(field); + writeln!(out, "{indent} {}: {},", field.name, rendered).unwrap(); + } + if let Some(u) = union { + write!(out, "{indent} {disc}: ", disc = u.discriminant_name).unwrap(); + emit_zod_union(out, &format!("{indent} "), u); + writeln!(out, ",").unwrap(); + } + write!(out, "{indent}}}).strict()").unwrap(); +} + +// Each variant is a single-key object. `z.union([...])` rather than +// `z.discriminatedUnion` because there's no discriminator field +// within the variant — the variant name IS the key. `.strict()` +// ensures each variant rejects keys belonging to its siblings, which +// is the runtime invariant that matches capnp's wire discriminant. +fn emit_zod_union(out: &mut String, indent: &str, u: &Union) { + writeln!(out, "z.union([").unwrap(); + for variant in &u.variants { + let inner = match &variant.ty { + FieldType::Scalar(ScalarType::Void) => "z.null()".to_owned(), + other => render_zod_type(other), + }; + writeln!( + out, + "{indent} z.object({{ {name}: {inner} }}).strict(),", + name = variant.name + ) + .unwrap(); + } + write!(out, "{indent}])").unwrap(); +} + +fn emit_ts_type(out: &mut String, s: &Struct) { + match (&s.fields[..], &s.union) { + (fields, None) => { + writeln!(out, "export interface {} {{", s.name).unwrap(); + for field in fields { + writeln!(out, " {}: {};", field.name, render_ts_type(&field.ty)).unwrap(); + } + writeln!(out, "}}").unwrap(); + } + (fields, Some(u)) => { + writeln!(out, "export interface {} {{", s.name).unwrap(); + for field in fields { + writeln!(out, " {}: {};", field.name, render_ts_type(&field.ty)).unwrap(); + } + writeln!(out, " {}: {};", u.discriminant_name, render_ts_union(u)).unwrap(); + writeln!(out, "}}").unwrap(); + } + } +} + +fn render_ts_union(u: &Union) -> String { + let mut parts: Vec = Vec::new(); + for variant in &u.variants { + let inner = match &variant.ty { + FieldType::Scalar(ScalarType::Void) => "null".to_owned(), + other => render_ts_type(other), + }; + parts.push(format!("{{ {name}: {inner} }}", name = variant.name)); + } + parts.join(" | ") +} + +fn render_field(field: &StructField) -> String { + render_zod_type(&field.ty) +} + +fn render_zod_type(t: &FieldType) -> String { + match t { + FieldType::Scalar(s) => render_zod_scalar(*s).to_owned(), + FieldType::StructRef(name) => format!("{name}Schema"), + FieldType::EnumRef(name) => format!("{name}Schema"), + FieldType::List(inner) => format!("z.array({})", render_zod_type(inner)), + } +} + +fn render_zod_scalar(s: ScalarType) -> &'static str { + match s { + ScalarType::Void => "z.void()", + ScalarType::Bool => "z.boolean()", + ScalarType::Int8 + | ScalarType::Int16 + | ScalarType::Int32 + | ScalarType::Int64 => "z.number().int()", + ScalarType::UInt8 + | ScalarType::UInt16 + | ScalarType::UInt32 + | ScalarType::UInt64 => "z.number().int().nonnegative()", + ScalarType::Float32 | ScalarType::Float64 => "z.number()", + ScalarType::Text => "z.string()", + ScalarType::Data => "z.instanceof(Uint8Array)", + } +} + +fn render_ts_type(t: &FieldType) -> String { + match t { + FieldType::Scalar(s) => render_ts_scalar(*s).to_owned(), + FieldType::StructRef(name) => name.clone(), + FieldType::EnumRef(name) => name.clone(), + FieldType::List(inner) => { + // `T[]` rather than `(T)[]` — TS array postfix is + // right-associative against itself + scalars + struct + // refs, so the bare form is unambiguous. When union types + // land they'll need explicit parens because `A | B[]` + // parses as `A | (B[])`; handle then, not now. + format!("{}[]", render_ts_type(inner)) + } + } +} + +fn render_ts_scalar(s: ScalarType) -> &'static str { + match s { + ScalarType::Void => "void", + ScalarType::Bool => "boolean", + ScalarType::Int8 + | ScalarType::Int16 + | ScalarType::Int32 + | ScalarType::Int64 + | ScalarType::UInt8 + | ScalarType::UInt16 + | ScalarType::UInt32 + | ScalarType::UInt64 + | ScalarType::Float32 + | ScalarType::Float64 => "number", + ScalarType::Text => "string", + ScalarType::Data => "Uint8Array", + } +} diff --git a/packages/schema-bridge/tests/integration.rs b/packages/schema-bridge/tests/integration.rs new file mode 100644 index 0000000..61c89cc --- /dev/null +++ b/packages/schema-bridge/tests/integration.rs @@ -0,0 +1,784 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 notme contributors +// Origin: lifted from cloister (AGPL-3.0) by sole author, contributed to notme under Apache-2.0 on 2026-05-18; see NOTICE. + +// Integration tests for schema-bridge. +// +// Build CodeGeneratorRequest messages by hand using capnp's builder +// API rather than shelling out to `capnp compile`. This keeps the +// test loop hermetic — no capnp CLI dependency, no fixture .capnp +// files to parse, just direct Rust → IR → zod. +// +// Coverage: +// - golden: a struct with scalar fields → expected zod source +// - golden: cross-struct reference → emits `OtherSchema` +// - fail-case: list field → UnmappedConstruct("list") +// - fail-case: top-level enum → UnmappedConstruct("enum") +// - fail-case: in-struct union → UnmappedConstruct("union (in-struct)") +// - fail-case: group field → UnmappedConstruct("group") + +use capnp::message::{Builder, HeapAllocator}; +use capnp::schema_capnp; + +use schema_bridge::error::SchemaBridgeError; +use schema_bridge::{inputs, outputs}; + +fn parse(message: &Builder) -> Result { + let reader = message.get_root_as_reader::()?; + inputs::capnp::parse(reader) +} + +// Set a node up as a file marker. Voids on capnp union variants are +// `set_(())` rather than `init_()` in 0.21+. +fn fill_file_node(mut n: schema_capnp::node::Builder<'_>, id: u64, display_name: &str) { + n.set_id(id); + n.set_display_name(display_name); + n.set_display_name_prefix_length(0); + n.set_file(()); +} + +// ── Golden: scalar struct ─────────────────────────────────────────── + +#[test] +fn struct_with_scalars_emits_zod() { + let mut message = Builder::new_default(); + { + let request = message.init_root::(); + let mut nodes = request.init_nodes(2); + fill_file_node(nodes.reborrow().get(0), 0xFFFE, "test.capnp"); + + let mut node = nodes.reborrow().get(1); + node.set_id(0xAAAA); + node.set_display_name("test.capnp:Greeting"); + node.set_display_name_prefix_length("test.capnp:".len() as u32); + let mut s = node.init_struct(); + s.set_discriminant_count(0); + let mut fields = s.init_fields(2); + { + let mut field = fields.reborrow().get(0); + field.set_name("subject"); + field.set_code_order(0); + let mut slot = field.init_slot(); + slot.reborrow().init_type().set_text(()); + } + { + let mut field = fields.reborrow().get(1); + field.set_name("loud"); + field.set_code_order(1); + let mut slot = field.init_slot(); + slot.reborrow().init_type().set_bool(()); + } + } + + let schema = parse(&message).expect("parse"); + let emitted = outputs::zod::emit(&schema).expect("emit"); + + assert!( + emitted.contains("export const GreetingSchema: z.ZodType"), + "emit missing schema decl:\n{emitted}" + ); + assert!(emitted.contains("subject: z.string()"), "emit:\n{emitted}"); + assert!(emitted.contains("loud: z.boolean()"), "emit:\n{emitted}"); + assert!(emitted.contains("export interface Greeting"), "emit:\n{emitted}"); + assert!(emitted.contains("subject: string;"), "emit:\n{emitted}"); + assert!(emitted.contains("loud: boolean;"), "emit:\n{emitted}"); +} + +// ── cloister-cf2e6a: struct z.object() must be .strict() ─────────── +// +// Without .strict(), zod silently drops unknown fields on parse. An +// operator typo like `holdsCredentials = ["SECRET"]` (extra 's') gets +// silently discarded — the credential vanishes with no diagnostic. +// .strict() turns the typo into a ZodError at the boundary where +// schema-bridge is the source of truth. +// +// Surfaced as skeptic N1 during cloister-ae06f3's adversarial review; +// filed as cloister-cf2e6a; fixed here. + +#[test] +fn struct_zod_object_is_strict() { + let mut message = Builder::new_default(); + { + let request = message.init_root::(); + let mut nodes = request.init_nodes(2); + fill_file_node(nodes.reborrow().get(0), 0xFFFE, "test.capnp"); + + let mut node = nodes.reborrow().get(1); + node.set_id(0xAAAA); + node.set_display_name("test.capnp:Strict"); + node.set_display_name_prefix_length("test.capnp:".len() as u32); + let mut s = node.init_struct(); + s.set_discriminant_count(0); + let mut fields = s.init_fields(1); + let mut field = fields.reborrow().get(0); + field.set_name("only"); + field.set_code_order(0); + field.init_slot().init_type().set_text(()); + } + + let schema = parse(&message).expect("parse"); + let emitted = outputs::zod::emit(&schema).expect("emit"); + + // The outer struct z.object MUST be terminated with .strict() so + // unknown keys are rejected at parse time (zod default is to + // silently drop them). Per cloister-cf2e6a / skeptic N1. + assert!( + emitted.contains("}).strict()"), + "struct z.object must be .strict() — emitted:\n{emitted}" + ); + // And the existing schema decl is still there. + assert!( + emitted.contains("export const StrictSchema: z.ZodType"), + "schema decl missing — emitted:\n{emitted}" + ); +} + +// ── Golden: struct-to-struct reference ───────────────────────────── + +#[test] +fn struct_ref_emits_named_schema() { + let mut message = Builder::new_default(); + let outer_id: u64 = 0xAAAA; + let inner_id: u64 = 0xBBBB; + { + let request = message.init_root::(); + let mut nodes = request.init_nodes(3); + fill_file_node(nodes.reborrow().get(0), 0xFFFE, "test.capnp"); + + // Outer { inner :Inner; } + { + let mut node = nodes.reborrow().get(1); + node.set_id(outer_id); + node.set_display_name("test.capnp:Outer"); + node.set_display_name_prefix_length("test.capnp:".len() as u32); + let mut s = node.init_struct(); + s.set_discriminant_count(0); + let mut fields = s.init_fields(1); + let mut field = fields.reborrow().get(0); + field.set_name("inner"); + field.set_code_order(0); + let mut slot = field.init_slot(); + let ty = slot.reborrow().init_type(); + let mut sty = ty.init_struct(); + sty.set_type_id(inner_id); + } + + // Inner { tag :Text; } + { + let mut node = nodes.reborrow().get(2); + node.set_id(inner_id); + node.set_display_name("test.capnp:Inner"); + node.set_display_name_prefix_length("test.capnp:".len() as u32); + let mut s = node.init_struct(); + s.set_discriminant_count(0); + let mut fields = s.init_fields(1); + let mut field = fields.reborrow().get(0); + field.set_name("tag"); + field.set_code_order(0); + let mut slot = field.init_slot(); + slot.reborrow().init_type().set_text(()); + } + } + + let schema = parse(&message).expect("parse"); + let emitted = outputs::zod::emit(&schema).expect("emit"); + + assert!(emitted.contains("inner: InnerSchema"), "emit:\n{emitted}"); + assert!(emitted.contains("inner: Inner;"), "emit:\n{emitted}"); +} + +// ── Golden: list of scalars ──────────────────────────────────────── + +#[test] +fn list_of_scalars_emits_array() { + let mut message = Builder::new_default(); + { + let request = message.init_root::(); + let mut nodes = request.init_nodes(2); + fill_file_node(nodes.reborrow().get(0), 0xFFFE, "test.capnp"); + + let mut node = nodes.reborrow().get(1); + node.set_id(0xAAAA); + node.set_display_name("test.capnp:HasList"); + node.set_display_name_prefix_length("test.capnp:".len() as u32); + let mut s = node.init_struct(); + s.set_discriminant_count(0); + let mut fields = s.init_fields(1); + let mut field = fields.reborrow().get(0); + field.set_name("tags"); + field.set_code_order(0); + let mut slot = field.init_slot(); + let ty = slot.reborrow().init_type(); + let list = ty.init_list(); + list.init_element_type().set_text(()); + } + + let schema = parse(&message).expect("parse"); + let emitted = outputs::zod::emit(&schema).expect("emit"); + assert!( + emitted.contains("tags: z.array(z.string())"), + "emit:\n{emitted}" + ); + assert!(emitted.contains("tags: string[];"), "emit:\n{emitted}"); +} + +// ── Golden: nested list of lists ─────────────────────────────────── + +#[test] +fn list_of_lists_recurses() { + let mut message = Builder::new_default(); + { + let request = message.init_root::(); + let mut nodes = request.init_nodes(2); + fill_file_node(nodes.reborrow().get(0), 0xFFFE, "test.capnp"); + + let mut node = nodes.reborrow().get(1); + node.set_id(0xAAAA); + node.set_display_name("test.capnp:Matrix"); + node.set_display_name_prefix_length("test.capnp:".len() as u32); + let mut s = node.init_struct(); + s.set_discriminant_count(0); + let mut fields = s.init_fields(1); + let mut field = fields.reborrow().get(0); + field.set_name("rows"); + field.set_code_order(0); + let mut slot = field.init_slot(); + let outer = slot.reborrow().init_type().init_list(); + let inner = outer.init_element_type().init_list(); + inner.init_element_type().set_int32(()); + } + + let schema = parse(&message).expect("parse"); + let emitted = outputs::zod::emit(&schema).expect("emit"); + assert!( + emitted.contains("rows: z.array(z.array(z.number().int()))"), + "emit:\n{emitted}" + ); + assert!(emitted.contains("rows: number[][];"), "emit:\n{emitted}"); +} + +// ── Regression-guard: list of an unmapped element still errors ──── + +#[test] +fn list_of_unmapped_element_fails_fast() { + let mut message = Builder::new_default(); + { + let request = message.init_root::(); + let mut nodes = request.init_nodes(2); + fill_file_node(nodes.reborrow().get(0), 0xFFFE, "test.capnp"); + + let mut node = nodes.reborrow().get(1); + node.set_id(0xAAAA); + node.set_display_name("test.capnp:HasInterfaces"); + node.set_display_name_prefix_length("test.capnp:".len() as u32); + let mut s = node.init_struct(); + s.set_discriminant_count(0); + let mut fields = s.init_fields(1); + let mut field = fields.reborrow().get(0); + field.set_name("services"); + field.set_code_order(0); + let mut slot = field.init_slot(); + let ty = slot.reborrow().init_type(); + let list = ty.init_list(); + let elem = list.init_element_type(); + elem.init_interface(); + } + + let err = parse(&message).expect_err("must reject list-of-interface"); + match err { + SchemaBridgeError::UnmappedConstruct { kind, .. } => { + assert_eq!(kind, "interface (type ref)"); + } + other => panic!("expected UnmappedConstruct('interface (type ref)'), got {other:?}"), + } +} + +// ── Golden: top-level enum + struct field of enum type ───────────── + +#[test] +fn enum_emits_zod_enum_and_string_union() { + let mut message = Builder::new_default(); + let enum_id: u64 = 0xCCCC; + { + let request = message.init_root::(); + let mut nodes = request.init_nodes(3); + fill_file_node(nodes.reborrow().get(0), 0xFFFE, "test.capnp"); + + // Enum Tier { hypervisor @0; cluster @1; } + { + let mut n = nodes.reborrow().get(1); + n.set_id(enum_id); + n.set_display_name("test.capnp:Tier"); + n.set_display_name_prefix_length("test.capnp:".len() as u32); + let e = n.init_enum(); + let mut enumerants = e.init_enumerants(2); + enumerants.reborrow().get(0).set_name("hypervisor"); + enumerants.reborrow().get(1).set_name("cluster"); + } + + // struct Bundle { tier @0 :Tier; } + { + let mut n = nodes.reborrow().get(2); + n.set_id(0xAAAA); + n.set_display_name("test.capnp:Bundle"); + n.set_display_name_prefix_length("test.capnp:".len() as u32); + let mut s = n.init_struct(); + s.set_discriminant_count(0); + let mut fields = s.init_fields(1); + let mut field = fields.reborrow().get(0); + field.set_name("tier"); + field.set_code_order(0); + let mut slot = field.init_slot(); + let ty = slot.reborrow().init_type(); + let mut et = ty.init_enum(); + et.set_type_id(enum_id); + } + } + + let schema = parse(&message).expect("parse"); + let emitted = outputs::zod::emit(&schema).expect("emit"); + assert!( + emitted.contains(r#"export const TierSchema = z.enum(["hypervisor", "cluster"]);"#), + "emit:\n{emitted}" + ); + assert!( + emitted.contains(r#"export type Tier = "hypervisor" | "cluster";"#), + "emit:\n{emitted}" + ); + assert!(emitted.contains("tier: TierSchema"), "emit:\n{emitted}"); + assert!(emitted.contains("tier: Tier;"), "emit:\n{emitted}"); +} + +// ── Regression-guard: anonymous inline union ────────────────────── +// +// `struct Foo { union { … } }` (no group wrapper). Real capnp form +// but cloister's schemas always use the `name :union { … }` sugar, +// so we don't emit for it yet. Kept as a fail-fast so a schema +// change to this form lights up. + +#[test] +fn anonymous_inline_union_fails_fast() { + let mut message = Builder::new_default(); + { + let request = message.init_root::(); + let mut nodes = request.init_nodes(2); + fill_file_node(nodes.reborrow().get(0), 0xFFFE, "test.capnp"); + + let mut node = nodes.reborrow().get(1); + node.set_id(0xAAAA); + node.set_display_name("test.capnp:Variant"); + node.set_display_name_prefix_length("test.capnp:".len() as u32); + let mut s = node.init_struct(); + s.set_discriminant_count(2); + } + + let err = parse(&message).expect_err("must reject anonymous inline union"); + match err { + SchemaBridgeError::UnmappedConstruct { kind, .. } => { + assert!( + kind.starts_with("anonymous inline union"), + "got kind {kind:?}" + ); + } + other => panic!("expected UnmappedConstruct, got {other:?}"), + } +} + +// ── Regression-guard: non-union group field ──────────────────────── +// +// `struct Foo { thing :group { a @0 :Int32 } }` (group field whose +// target struct has no union) is a real capnp form for field +// namespacing. Unused in cloister; reject loudly. + +#[test] +fn non_union_group_fails_fast() { + let mut message = Builder::new_default(); + let outer_id: u64 = 0xAAAA; + let group_id: u64 = 0xBBBB; + { + let request = message.init_root::(); + let mut nodes = request.init_nodes(3); + fill_file_node(nodes.reborrow().get(0), 0xFFFE, "test.capnp"); + + // Outer struct with a `nested` group field. + { + let mut node = nodes.reborrow().get(1); + node.set_id(outer_id); + node.set_display_name("test.capnp:WithGroup"); + node.set_display_name_prefix_length("test.capnp:".len() as u32); + let mut s = node.init_struct(); + s.set_discriminant_count(0); + let mut fields = s.init_fields(1); + let mut field = fields.reborrow().get(0); + field.set_name("nested"); + field.set_code_order(0); + field.set_discriminant_value(0xffff); + let mut group = field.init_group(); + group.set_type_id(group_id); + } + + // The group node — a struct with no union (discriminant_count = 0). + { + let mut node = nodes.reborrow().get(2); + node.set_id(group_id); + node.set_display_name("test.capnp:WithGroup.nested"); + node.set_display_name_prefix_length("test.capnp:".len() as u32); + let mut s = node.init_struct(); + s.set_is_group(true); + s.set_discriminant_count(0); + // Field on the group — body doesn't matter for the test. + let mut fields = s.init_fields(1); + let mut field = fields.reborrow().get(0); + field.set_name("a"); + field.set_code_order(0); + let mut slot = field.init_slot(); + slot.reborrow().init_type().set_int32(()); + } + } + + let err = parse(&message).expect_err("must reject non-union group"); + match err { + SchemaBridgeError::UnmappedConstruct { kind, .. } => { + assert_eq!(kind, "non-union group"); + } + other => panic!("expected UnmappedConstruct('non-union group'), got {other:?}"), + } +} + +// ── Golden: named union via group, struct variants ──────────────── +// +// The shape used by `Backend.kind :union { durableObject @2 :DoBackend; +// httpForward @3 :HttpForwardBackend; … }` in manifest/cloister.capnp. + +#[test] +fn named_union_struct_variants_emits_discriminated_union() { + let mut message = Builder::new_default(); + let backend_id: u64 = 0xAAAA; + let kind_group_id: u64 = 0xBBBB; + let do_backend_id: u64 = 0xCCCC; + let http_backend_id: u64 = 0xDDDD; + { + let request = message.init_root::(); + let mut nodes = request.init_nodes(5); + fill_file_node(nodes.reborrow().get(0), 0xFFFE, "test.capnp"); + + // Backend struct with name + kind union. + { + let mut node = nodes.reborrow().get(1); + node.set_id(backend_id); + node.set_display_name("test.capnp:Backend"); + node.set_display_name_prefix_length("test.capnp:".len() as u32); + let mut s = node.init_struct(); + s.set_discriminant_count(0); + let mut fields = s.init_fields(2); + // name @0 :Text + { + let mut field = fields.reborrow().get(0); + field.set_name("name"); + field.set_code_order(0); + field.set_discriminant_value(0xffff); + let mut slot = field.init_slot(); + slot.reborrow().init_type().set_text(()); + } + // kind :group { union { ... } } + { + let mut field = fields.reborrow().get(1); + field.set_name("kind"); + field.set_code_order(1); + field.set_discriminant_value(0xffff); + let mut group = field.init_group(); + group.set_type_id(kind_group_id); + } + } + + // The kind group: anonymous struct, discriminant_count = 2. + { + let mut node = nodes.reborrow().get(2); + node.set_id(kind_group_id); + node.set_display_name("test.capnp:Backend.kind"); + node.set_display_name_prefix_length("test.capnp:".len() as u32); + let mut s = node.init_struct(); + s.set_is_group(true); + s.set_discriminant_count(2); + let mut fields = s.init_fields(2); + // durableObject (discriminant 0) → :DoBackend + { + let mut field = fields.reborrow().get(0); + field.set_name("durableObject"); + field.set_code_order(0); + field.set_discriminant_value(0); + let mut slot = field.init_slot(); + let ty = slot.reborrow().init_type(); + let mut sty = ty.init_struct(); + sty.set_type_id(do_backend_id); + } + // httpForward (discriminant 1) → :HttpForwardBackend + { + let mut field = fields.reborrow().get(1); + field.set_name("httpForward"); + field.set_code_order(1); + field.set_discriminant_value(1); + let mut slot = field.init_slot(); + let ty = slot.reborrow().init_type(); + let mut sty = ty.init_struct(); + sty.set_type_id(http_backend_id); + } + } + + // DoBackend and HttpForwardBackend — trivial structs, refs only. + for (i, (id, name)) in [(do_backend_id, "DoBackend"), (http_backend_id, "HttpForwardBackend")] + .into_iter() + .enumerate() + { + let mut node = nodes.reborrow().get(3 + i as u32); + node.set_id(id); + node.set_display_name(&format!("test.capnp:{name}")); + node.set_display_name_prefix_length("test.capnp:".len() as u32); + let mut s = node.init_struct(); + s.set_discriminant_count(0); + s.init_fields(0); + } + } + + let schema = parse(&message).expect("parse"); + let emitted = outputs::zod::emit(&schema).expect("emit"); + + // zod side: union variants are NESTED under the discriminant + // name ("kind"), one variant per single-key object, with .strict() + // to enforce exactly-one. This matches capnp's JSON convention: + // `"kind": { "durableObject": {…} }`. + assert!( + emitted.contains("kind: z.union(["), + "emit:\n{emitted}" + ); + assert!( + emitted.contains("z.object({ durableObject: DoBackendSchema }).strict()"), + "emit:\n{emitted}" + ); + assert!( + emitted.contains("z.object({ httpForward: HttpForwardBackendSchema }).strict()"), + "emit:\n{emitted}" + ); + // No intersection wrapper now — base fields are siblings of the + // nested union object in a single z.object(). + assert!( + !emitted.contains("z.intersection"), + "should NOT use z.intersection under the new shape.\nemit:\n{emitted}" + ); + + // TS side: interface with the union field typed as a nested- + // object union. + assert!( + emitted.contains("export interface Backend {"), + "emit:\n{emitted}" + ); + assert!( + emitted.contains("kind: { durableObject: DoBackend } | { httpForward: HttpForwardBackend };"), + "emit:\n{emitted}" + ); +} + +// ── Golden: named union with Void variants (pure discriminator) ─── +// +// The shape used by `Wire.transport :union { uds @3 :Void; leylineNet +// @4 :Void; }` in manifest/cluster.capnp. No payload on either +// variant — just the discriminant. + +#[test] +fn named_union_void_variants_omits_payload() { + let mut message = Builder::new_default(); + let wire_id: u64 = 0xAAAA; + let transport_group_id: u64 = 0xBBBB; + { + let request = message.init_root::(); + let mut nodes = request.init_nodes(3); + fill_file_node(nodes.reborrow().get(0), 0xFFFE, "test.capnp"); + + // Wire struct: only the transport union, no base fields. + { + let mut node = nodes.reborrow().get(1); + node.set_id(wire_id); + node.set_display_name("test.capnp:Wire"); + node.set_display_name_prefix_length("test.capnp:".len() as u32); + let mut s = node.init_struct(); + s.set_discriminant_count(0); + let mut fields = s.init_fields(1); + let mut field = fields.reborrow().get(0); + field.set_name("transport"); + field.set_code_order(0); + field.set_discriminant_value(0xffff); + let mut group = field.init_group(); + group.set_type_id(transport_group_id); + } + + // transport group: union { uds @3 :Void; leylineNet @4 :Void; } + { + let mut node = nodes.reborrow().get(2); + node.set_id(transport_group_id); + node.set_display_name("test.capnp:Wire.transport"); + node.set_display_name_prefix_length("test.capnp:".len() as u32); + let mut s = node.init_struct(); + s.set_is_group(true); + s.set_discriminant_count(2); + let mut fields = s.init_fields(2); + for (i, name) in ["uds", "leylineNet"].iter().enumerate() { + let mut field = fields.reborrow().get(i as u32); + field.set_name(name); + field.set_code_order(i as u16); + field.set_discriminant_value(i as u16); + let mut slot = field.init_slot(); + slot.reborrow().init_type().set_void(()); + } + } + } + + let schema = parse(&message).expect("parse"); + let emitted = outputs::zod::emit(&schema).expect("emit"); + + // zod: Void variants emit as `{ name: z.null() }` (matches + // capnp's JSON convention `"transport": { "uds": null }`). + assert!( + emitted.contains("transport: z.union(["), + "emit:\n{emitted}" + ); + assert!( + emitted.contains("z.object({ uds: z.null() }).strict()"), + "emit:\n{emitted}" + ); + assert!( + emitted.contains("z.object({ leylineNet: z.null() }).strict()"), + "emit:\n{emitted}" + ); + + // TS: interface with the transport field typed as a nested + // object union over `null` payloads. + assert!( + emitted.contains("export interface Wire {"), + "emit:\n{emitted}" + ); + assert!( + emitted.contains("transport: { uds: null } | { leylineNet: null };"), + "emit:\n{emitted}" + ); +} + +// ── Regression-guard: $Json.flatten annotation on a union field ─── +// +// `$Json.flatten` changes capnp's JSON encoding from the nested +// `"kind": { "variant": payload }` form to the flat-with-variant-name +// form. Our v1 emit assumes the nested form; an annotated field +// would produce a schema that silently rejects the JSON. Fail loudly +// so the day someone adds `$Json.flatten` the codegen lights up. +// Annotation id `@0x82d3e852af0336bf` is from capnp/compat/json.capnp. + +#[test] +fn json_flatten_annotation_fails_fast() { + let mut message = Builder::new_default(); + { + let request = message.init_root::(); + let mut nodes = request.init_nodes(2); + fill_file_node(nodes.reborrow().get(0), 0xFFFE, "test.capnp"); + + let mut node = nodes.reborrow().get(1); + node.set_id(0xAAAA); + node.set_display_name("test.capnp:Annotated"); + node.set_display_name_prefix_length("test.capnp:".len() as u32); + let mut s = node.init_struct(); + s.set_discriminant_count(0); + let mut fields = s.init_fields(1); + let mut field = fields.reborrow().get(0); + field.set_name("payload"); + field.set_code_order(0); + field.set_discriminant_value(0xffff); + let mut anns = field.reborrow().init_annotations(1); + anns.reborrow().get(0).set_id(0x82d3e852af0336bf); + let mut slot = field.init_slot(); + slot.reborrow().init_type().set_text(()); + } + + let err = parse(&message).expect_err("must reject $Json.flatten"); + match err { + SchemaBridgeError::UnmappedConstruct { kind, .. } => { + assert_eq!(kind, "annotation `$Json.flatten`"); + } + other => panic!("expected UnmappedConstruct, got {other:?}"), + } +} + +// ── Regression-guard: unknown annotation reports raw hex id ─────── + +#[test] +fn unknown_annotation_fails_fast_with_hex_id() { + let mut message = Builder::new_default(); + { + let request = message.init_root::(); + let mut nodes = request.init_nodes(2); + fill_file_node(nodes.reborrow().get(0), 0xFFFE, "test.capnp"); + + let mut node = nodes.reborrow().get(1); + node.set_id(0xAAAA); + node.set_display_name("test.capnp:Annotated"); + node.set_display_name_prefix_length("test.capnp:".len() as u32); + let mut anns = node.reborrow().init_annotations(1); + // arbitrary id, NOT one of the known json.* ids + anns.reborrow().get(0).set_id(0xCAFEBABEu64); + let mut s = node.init_struct(); + s.set_discriminant_count(0); + s.init_fields(0); + } + + let err = parse(&message).expect_err("must reject unknown annotation"); + match err { + SchemaBridgeError::UnmappedConstruct { kind, .. } => { + assert!(kind.starts_with("annotation @"), "got kind {kind:?}"); + assert!(kind.contains("cafebabe"), "got kind {kind:?}"); + } + other => panic!("expected UnmappedConstruct, got {other:?}"), + } +} + +// ── Aspirational stubs (#[ignore]'d) ────────────────────────────── +// +// Cargo prints `X ignored` on every run, so these gaps stay visible +// without breaking the suite. Each stub documents what the eventual +// success looks like; removing `#[ignore]` is the activation gesture +// once support lands. Paired with the regression-guard fail-fast +// tests above — those stay forever, these go green and stay. + +// $Json.flatten changes the union encoding from +// { kind: { variant: payload } } +// to flat +// { variant: payload } +// alongside base fields. Different emit shape; future work. +#[test] +#[ignore = "schema-bridge does not yet emit the flat shape for $Json.flatten"] +fn flat_union_emit_under_json_flatten() { + // When implemented, this test should: + // - build a struct with a $Json.flatten-annotated union group + // - parse it + // - assert the emitted zod is `z.object({ ...base, ...union })` + // where union variants are siblings of base fields, not nested + // under the discriminant name + // - assert the emitted TS type intersects the variants directly + unimplemented!("activate once schema-bridge handles `$Json.flatten`") +} + +// Anonymous inline unions (`struct Foo { union { ... } }` with no +// group wrapping) encode flat — variant name is a sibling key on the +// parent struct, not nested under any group name. Same emit shape as +// $Json.flatten conceptually; different parse path. +#[test] +#[ignore = "schema-bridge does not yet emit for anonymous inline unions"] +fn anonymous_inline_union_emits_flat() { + unimplemented!("activate once schema-bridge handles anonymous inline unions") +} + +// Non-union groups (`field :group { x @0 :T; y @1 :U; }`) are field +// namespacing without a discriminator. Capnp's JSON encodes them as a +// nested object under the group name. Future emit: +// `field: z.object({ x: ..., y: ... })`. +#[test] +#[ignore = "schema-bridge does not yet emit for non-union groups"] +fn non_union_group_emits_nested_object() { + unimplemented!("activate once schema-bridge handles non-union groups") +} From 334056b0c60adc19dee4dce2423ca95db073b0ac Mon Sep 17 00:00:00 2001 From: jamestexas <18285880+jamestexas@users.noreply.github.com> Date: Mon, 18 May 2026 13:47:14 -0600 Subject: [PATCH 2/3] [rosary-1b914d] fix(schema-bridge): address Copilot review nits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Taskfile schema-bridge:build now uses --locked - gen:zod:check-drift gives a clearer error when baseline missing - atomic-move comment in gen:zod now honestly describes mv semantics - emitted zod.rs regen hint + README references switch to gen:zod / gen/ts paths - README Layout updated to packages/schema-bridge/ (was tools/...) - README "Follow-on" license claim corrected to Apache-2.0 (matches Cargo.toml + NOTICE) - Cargo.toml: drop unused indoc dev-dep All from Copilot review on PR #21. No falsification regression — code under src/ unchanged; only Taskfile, emit template, README, and dev-deps touched. --- Taskfile.yml | 23 ++++++++++++++++++----- packages/schema-bridge/Cargo.lock | 16 ---------------- packages/schema-bridge/Cargo.toml | 3 --- packages/schema-bridge/README.md | 12 ++++++------ packages/schema-bridge/src/outputs/zod.rs | 2 +- 5 files changed, 25 insertions(+), 31 deletions(-) diff --git a/Taskfile.yml b/Taskfile.yml index d3e7a0d..8fd4be9 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -225,7 +225,7 @@ tasks: generates: - target/release/capnpc-schema-bridge cmds: - - cargo build --release + - cargo build --locked --release gen:zod: desc: Regenerate gen/ts/*.zod.ts from schema/*.capnp via schema-bridge @@ -233,11 +233,14 @@ tasks: cmds: # `capnp compile` exits 0 even when the plugin errors and writes # nothing — its stderr carries the real diagnostic. Generate into - # a tmpdir + existence-check + atomic move. Mirrors the cloister - # cluster:zod pattern; same rationale. + # a sibling tmpdir under gen/ts/ (same filesystem as destination) + # so the final `mv` is a same-fs rename — atomic on POSIX. A + # plain `mktemp -d` lands in $TMPDIR (often /tmp, a separate fs), + # which silently degrades the rename to a cross-fs copy+unlink. - | set -eu - TMPDIR=$(mktemp -d) + mkdir -p gen/ts + TMPDIR=$(mktemp -d -p gen/ts) trap 'rm -rf "$TMPDIR"' EXIT capnp compile \ -o./packages/schema-bridge/target/release/capnpc-schema-bridge:"$TMPDIR" \ @@ -255,7 +258,17 @@ tasks: cmds: - | set -eu - TMPDIR=$(mktemp -d) + # Baseline must exist before we can diff against it. Until the + # schema-bridge Go-annotation gap (rosary-8d2c78) is closed, + # `task gen:zod` against identity.capnp errors out, so no + # baseline is committed. Emit a clearer error than the raw + # `diff: gen/ts/identity.zod.ts: No such file or directory`. + if [ ! -f gen/ts/identity.zod.ts ]; then + echo "FAIL — gen/ts/identity.zod.ts baseline missing; run \`task gen:zod\` and commit the output first (blocked on rosary-8d2c78)." >&2 + exit 1 + fi + mkdir -p gen/ts + TMPDIR=$(mktemp -d -p gen/ts) trap 'rm -rf "$TMPDIR"' EXIT capnp compile \ -o./packages/schema-bridge/target/release/capnpc-schema-bridge:"$TMPDIR" \ diff --git a/packages/schema-bridge/Cargo.lock b/packages/schema-bridge/Cargo.lock index 3cf5e63..be74e28 100644 --- a/packages/schema-bridge/Cargo.lock +++ b/packages/schema-bridge/Cargo.lock @@ -17,15 +17,6 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" -[[package]] -name = "indoc" -version = "2.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" -dependencies = [ - "rustversion", -] - [[package]] name = "proc-macro2" version = "1.0.106" @@ -44,18 +35,11 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "rustversion" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" - [[package]] name = "schema-bridge" version = "0.0.1" dependencies = [ "capnp", - "indoc", "thiserror", ] diff --git a/packages/schema-bridge/Cargo.toml b/packages/schema-bridge/Cargo.toml index 567d350..a9efb20 100644 --- a/packages/schema-bridge/Cargo.toml +++ b/packages/schema-bridge/Cargo.toml @@ -17,6 +17,3 @@ path = "src/lib.rs" [dependencies] capnp = "0.21" thiserror = "2.0" - -[dev-dependencies] -indoc = "2.0" diff --git a/packages/schema-bridge/README.md b/packages/schema-bridge/README.md index 0f8fb06..63e5097 100644 --- a/packages/schema-bridge/README.md +++ b/packages/schema-bridge/README.md @@ -33,13 +33,13 @@ loud. notme's older `capnp-to-ts.ts` (which this tool replaces in spirit) silently emitted `z.unknown()` for unrecognised constructs; that's the precise failure mode schema-bridge exists to prevent. -**Today the codegen is opt-in** — `task cluster:zod` regenerates -`src/generated/cluster.zod.ts` and `task cluster:zod:check-drift` +**Today the codegen is opt-in** — `task gen:zod` regenerates +`gen/ts/identity.zod.ts` and `task gen:zod:check-drift` verifies the committed copy matches. Neither task is wired into `task lint` or `task verify` yet, so an unmapped capnp construct won't break CI automatically; it WILL break the moment a developer runs the regen or drift-check task locally. The plan is to wire -`cluster:zod:check-drift` into `task verify` once the schema-bridge +`gen:zod:check-drift` into `task verify` once the schema-bridge mapping coverage stabilises (tracked separately) — at that point unmapped constructs become a hard CI failure. No silent fallbacks regardless. @@ -136,7 +136,7 @@ needed in CI). ## Layout ``` -tools/schema-bridge/ +packages/schema-bridge/ ├── Cargo.toml standalone workspace; depends only on capnp + thiserror ├── README.md this file ├── src/ @@ -169,8 +169,8 @@ Tracked separately from this initial drop. In rough priority order: 5. End-to-end fixture tests against `manifest/*.capnp` — currently verified manually (see README "What's mapped today"); locking that in as a golden-output test in CI prevents silent regressions. -6. License — deferred per the implementation conversation. Default - matches cloister (AGPL-3.0-or-later); revisit if extraction to a +6. License — this crate ships as Apache-2.0 (see `Cargo.toml`, + source headers, and `NOTICE`). Revisit if extraction to a standalone repo lands. ## Non-goals (the helm comparison) diff --git a/packages/schema-bridge/src/outputs/zod.rs b/packages/schema-bridge/src/outputs/zod.rs index 9c7205d..245ff12 100644 --- a/packages/schema-bridge/src/outputs/zod.rs +++ b/packages/schema-bridge/src/outputs/zod.rs @@ -18,7 +18,7 @@ pub fn emit(schema: &Schema) -> Result { writeln!(out, "// Generated by schema-bridge — do not edit by hand.").unwrap(); writeln!( out, - "// Regenerate with `task cluster:zod` (runs the schema-bridge plugin)." + "// Regenerate with `task gen:zod` (runs the schema-bridge plugin)." ) .unwrap(); writeln!(out, "//").unwrap(); From 0900c4591796507abc1f2fbaf515c24e54b93cfa Mon Sep 17 00:00:00 2001 From: jamestexas <18285880+jamestexas@users.noreply.github.com> Date: Mon, 18 May 2026 14:17:02 -0600 Subject: [PATCH 3/3] ci: trigger run after Actions re-enabled