feat: CDDL-to-Zod generator, verified against wire-mesh's conformance vectors - #3
Merged
Conversation
wire-mesh's CDDL spec needs three parser fixes not yet released upstream: operators on unnamed array members no longer crash the parser, .cbor/.cborseq are recognised as valid control operators, and the ? occurrence indicator no longer parses with the same unbounded upper bound as *. Each is tracked in webdriverio/cddl#87/#88/#89/#90; this vendors the patched source (compiled via its own tsc step, not modified to fit any other execution model) until they ship.
Mirrors wire-mesh's conformance/ package convention: pnpm + Node's native TS execution for scripts, tsdown for the dual ESM/CJS library build, attw verifying the published package surface, a split tsconfig (tightly scoped to the build entry vs. a looser one covering scripts/tests/configs), and underscore-prefixed turbo task names so the public package.json scripts don't recursively resolve to themselves.
emitModule walks the vendored parser's AST and produces a TypeScript module with one z.lazy()-wrapped schema plus one inferred type per rule, scoped to the CDDL subset wire-mesh's spec actually uses: maps (with at most one open key => value tail, resolving an arrow-syntax key to its literal wire value when it names a single-literal rule rather than the rule's own name), fixed and homogeneous arrays, socket unions (a rule's base definition plus every /= choice-addition, whatever mix of map/array/variable shapes they use), and the .size/.regexp/.cbor/.cborseq control operators. Anything outside that subset throws a clear, rule-named error rather than silently emitting something wrong. cborDecodesAs (src/runtime.ts) validates a .cbor/.cborseq bstr field's embedded content against its referenced schema without transforming the field itself, so re-encoding a validated value reproduces the exact bytes that were decoded. src/cli.ts is the thin script entry (node src/cli.ts <input.cddl> <output.ts>), run directly rather than through the packaged library surface.
For every vector in wire-mesh's frames/handshake/tokens conformance files (copied here as fixtures, alongside its real spec/protocol.cddl), decodes the recorded wire_hex, validates the result against the schema generated from protocol.cddl, and re-encodes it -- asserting the output matches wire_hex exactly. This is cddl.js's actual definition of done: not that the generator runs without throwing, but that its output round-trips real, previously-agreed wire bytes byte-for-byte. The generated module is written to test/fixtures/generated/protocol.ts at test time (gitignored) rather than committed, since it's a deterministic function of the fixture .cddl; a sibling runtime.ts re-exports src/runtime.ts so the generated module's own "./runtime.js" import resolves without duplicating that file's content.
The generator's own definition of done -- conformance vectors round-trip byte-exactly through the generated schemas -- needs to actually run somewhere other than a developer's machine to mean anything.
Mearman
marked this pull request as ready for review
September 10, 2026 08:47
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Builds the actual generator the foundation/spike decisions (#1, #2) committed to:
emitModulewalks a parsed CDDL AST and emits one Zod schema plus one inferred type per rule, scoped to the subset wire-mesh'sspec/protocol.cddluses -- maps (including the open-tail and literal-keyed-arrow-entry patterns COSE headers need), fixed/homogeneous arrays, socket unions, and the.size/.regexp/.cbor/.cborseqoperators. Anything outside that subset throws a clear, rule-named error.The vendored parser (
vendor/cddl/) picks up a third upstream fix beyond the two already tracked:?was parsing with the same unbounded upper bound as*, which made an arrow-syntax entry keyed by a literal-valued rule reference (? cose-header-alg => int) indistinguishable from the generic open-map-tail pattern in the same map. Filed as webdriverio/cddl#90, fixed on the same fork stack at Mearman/cddl#2.test/round-trip.test.tsis the real proof: for every vector in wire-mesh'sframes/handshake/tokensconformance files (copied in as fixtures alongside its realspec/protocol.cddl), it decodes the recordedwire_hex, validates the result against the generated schema, and re-encodes it -- asserting the output matcheswire_hexexactly. 31/31 vectors round-trip byte-for-byte.Two bugs the round-trip test itself caught before they'd have hit a real consumer: decoding into a Node
Bufferrather than a plainUint8Array(cbor2'ssubarray()slices inheritBuffer, which its own encoder doesn't recognise -- the same gotchaconformance/codec.tsdocuments in the reverse direction), andcborDecodesAsoriginally transforming a.cborfield into its decoded value rather than validating and passing the original bytes through, which broke re-encoding.