Conformance suite
Every backend must pass the same cases: the suite is the oracle that keeps the seven targets
identical. Expected JSON and bytes are computed independently of every backend, by hand or by the
small generators in conformance/scripts/, never by running a backend.
1. Layout
conformance/
cases/<area>/schema.yfl entry schema (it may import other .yfl files in the directory)
cases/<area>/cases.json the cases (§2), generated by scripts/areas/<area>.ts
cases/<area>/yaffle.json optional: extern implementations per target (§5)
externs/<target>/ the conformance externs for each target (§5)
scripts/ case generators, their byte/JSON library (lib.ts), gen-cases.ts
src/ runner, case loader, canonical-JSON comparison, reports, CLI
test/conformance.test.ts vitest entry: every case on every available backend
test/*.test.ts tests of the runner, the generators and the externs
Each backend provides an adapter at packages/compiler/src/backends/<target>/conformance.ts
exporting adapter: ConformanceAdapter. The contract is conformance/src/adapter.ts:
export interface ConformanceAdapter {
target: string; // "ts", "rust", …
capabilities?: { views?: boolean }; // views: also run as "<target>:view" (§7)
available(): Promise<boolean>; // is the toolchain usable here?
run(input: AdapterRunInput): Promise<CaseResult[]>;
}
export interface AdapterRunInput {
caseDir: string; // absolute
program: IrProgram; // the case directory's schema.yfl, compiled by the frontend
cases: Case[]; // normalized by the runner (below)
workDir: string; // scratch directory for this target and case directory
externs: Record<string, string>; // extern name → module for this target (§5)
mode?: "parse" | "view"; // §7
}
export interface CaseResult {
name: string;
ok: boolean; // the adapter's own verdict
message?: string;
skipped?: string; // the target can't run this case (unsupported feature): reason
json?: unknown; // actual canonical JSON (parse cases): always return it
bytes?: string; // actual output hex (serialize, or the round trip of a parse case)
error?: { phase; code; path?; offset?; message? }; // the error raised, if any
}
Adapters should be thin. They return json, bytes and error, and the runner re-checks
them with its own comparison, so every backend gets the same diffs. bytes may be left out when
it would be huge (large files); then only ok counts for the round trip. An adapter that throws
an error whose message contains “not implemented” skips the whole case directory for its target;
any other throw fails every case of the directory.
What adapters receive (Case, normalized by the runner): bytes is canonical hex (no
whitespace) or { file: "<absolute path>" }; slices are already cut into a scratch file; expect and description are removed.
2. cases.json
{
"description": "…", // optional
"generatedBy": "conformance/scripts/areas/arrays.ts", // optional
"cases": [
{
"name": "basic",
"root": "Table", // exported struct
"args": { "version": 144 }, // root parameters (canonical JSON), optional
"bytes": "0300000001020304", // hex (spaces and _ allowed), or { "file": "input.bin" }
"json": { "count": 3, "entries": [ … ] }, // expected parse result (canonical JSON)
"roundtrip": true, // default true (see below)
"strict": false // parse in strict mode
},
{
"name": "bad magic",
"root": "Table",
"bytes": "ffff",
"error": { "phase": "parse", "code": "CHECK", "path": ["magic"] }
},
{
"name": "write derives count",
"root": "Table",
"input": { "entries": [ … ] }, // serialize-only case: input JSON (count omitted)
"bytes": "…" // expected output
},
{
"name": "large sample",
"root": "Archive",
"bytes": { "file": "sample.bin", "offset": 768, "length": 4160320 },
"expect": [ { "pointer": "/count", "equals": 6 }, { "pointer": "/names", "length": 598 } ]
}
]
}
- Parse cases (
bytes+jsonand/orexpect):toJson(parse(bytes))must deep-equaljson(§3), and everyexpectcheck must hold on it. - Round trips of parse cases:
true/"json"(the default):serialize(fromJson(json))must equalbytes, wherejsonis the actual canonical JSON (equal to the expected one when the case has it);"value":serialize(parse(bytes))must equalbytesin the same process, so codec reuse applies (IR.md §3.6). Used for codecs whose re-encoding differs from the original bytes;false: no round trip (non-canonical input, e.g. non-zero padding or a NaN payload).
- Serialize cases (
input+bytes):serialize(fromJson(input))must equalbytes. - Error cases: the phase and code must match, and the path too if given.
bytes: hex, or{ "file", "offset"?, "length"? }. A file is relative to the case directory.offset/lengthcut a slice.expect(runner-side checks on the actual JSON, for files too big to spell out): a list of{ "pointer": "<JSON Pointer>", "equals"?: <canonical JSON>, "length"?: n, "byteLength"?: n, "startsWith"?: "<prefix>" }.lengthis an array’s or string’s length,byteLengtha hex string’s byte count.
The case loader validates every cases.json (unknown keys, duplicate names, bad hex, missing files,
error codes, mutually exclusive fields); an invalid directory fails all its cases. cases.json is
never edited by hand: change the generator and run node conformance/scripts/gen-cases.ts.
Rules the cases pin beyond §3 and §4:
- Bytes after the root struct are ignored by parse.
- Computed fields are not checked on read (a wrong crc32 or size parses; the rebuild recomputes it), except constants.
- A count larger than the remaining data is
EOF, whatever its magnitude. @refresolves by identity first (the input object itself, e.g. restored by$ref), then by equal encoding (the first placed value wins).- On write, fields of an untaken conditional branch are ignored; a missing field of the taken
branch is
INPUT.
Known gaps:
- The suite has no negative compile cases (diagnostics are covered by the frontend’s own tests) and no surgical-write or async-source cases (view mode covers reads and no-op commits).
3. Canonical JSON
This is the JSON form of values, used by toJson/fromJson in every backend and by the cases.
| Value | JSON |
|---|---|
| struct | object, fields in declaration order (fields from blocks, switches and bits inline), only output fields. Fields of untaken conditional branches are omitted. Derived, computed and constant fields are output fields. |
| integer ≤ 32 bits, or u40/u48/i40/i48 | number |
| 56- and 64-bit integer | decimal string, e.g. "18446744073709551615", also when small ("1") |
| float | number. NaN, ±Infinity and -0 are the strings "NaN", "Infinity", "-Infinity" and "-0". |
| bool | true / false |
bytes (u8 arrays only) | lowercase hex string; other element types are JSON arrays |
| string | string |
| array | array |
| enum | member name; an open unknown value follows the integer rule of the storage (a number, or a decimal string for 56/64-bit storage) |
| pointer | the target’s JSON; null pointer → null |
| shared target (2nd and later occurrence, same object) | { "$ref": "/records/1/model" }, a JSON Pointer (RFC 6901) to the first occurrence, in document order. Only for object- and array-valued targets (structs, unions, non-byte arrays); scalars, strings and bytes are always written out. A $ref may point at an ancestor (cycles). |
| union | { "type": "<Variant>", "value": … } |
| transform | the transformed value’s JSON (one-way transforms: output only; the input is the raw value) |
| extern type | JSON of its value type |
| bit field | per its declared type (u64 lo : 40 is a decimal string) |
bound field u32 $version | the key without $ ("version") |
Floats: all NaNs read as "NaN"; writing "NaN" gives the canonical quiet NaN (f16 7e00, f32
7fc00000, f64 7ff8000000000000). A number written to a narrower float rounds to nearest, ties
to even. Key order is significant: the runner reports fields out of declaration order.
fromJson accepts this form. Fields that are absent from the input type are ignored if
present, so fromJson(toJson(x)) works. $ref restores object identity; a dangling $ref is an
INPUT error.
Root arguments (args): explicit parameters by name, bound parameters without $, and
endian as "le" or "be" (default "le").
4. Errors
| Code | Meaning |
|---|---|
EOF | not enough bytes (including an extern type’s size asking for more at the end) |
CHECK | a check failed (constant, in, where, regex, strict padding or reserved bits, bool, enum, string encoding, switch without match), on read or on write |
RANGE | an integer out of range for its destination, division by zero, a negative length, an index out of range |
UNION | no union variant matched |
CODEC | a codec failed |
INPUT | invalid input value on write: wrong JSON type, missing required field, string too long (or containing NUL), content larger than @padUntil, an element equal to an until terminator, unknown enum name or union variant, value missing from an indexOf inverse, a transform that doesn’t round-trip, a dangling $ref |
DERIVE | a derived value contradicts the data: shared counts disagree, an indivisible derived count, a non-derivable count or a constant length that doesn’t match the array, a size mismatch, piece sizes that don’t cover a stream |
REF | a @ref pointer has no equal placed value |
OVERLAP | fixed placements overlap (each other or inline bytes), or a stream piece doesn’t fit its block |
Error objects expose code, path, offset (absolute, or -1 on write) and message.
path lists field names and array indices from the root to the innermost field (or element)
being read or written when the error happened. Blocks, switches and bits add no segment (their
fields are flat); pointers are transparent. Examples: ["entries", 2, "x"], ["ifd", "count"].
A case leaves the path out where it is not determined (e.g. DERIVE between two fields, reserved
bits, a switch without a match).
5. Externs in cases
A case directory whose schema declares externs has a yaffle.json mapping each extern to a host
implementation per target, with paths relative to that yaffle.json:
{
"sources": ["schema.yfl"],
"targets": {
"ts": { "out": "…", "externs": { "rle": "../../externs/ts/rle.ts" } },
"rust": { "out": "…", "externs": { "rle": "../../externs/rust/rle.rs#RLE" } }
}
}
The runner resolves them and passes the adapter its own target’s map (AdapterRunInput.externs):
file paths (starting with ./, ../ or /) become absolute and keep an optional #ITEM suffix
naming the item in that file; anything else (e.g. a Rust path like crate::hash::gt_hash) is
passed through unchanged. A mapped file that doesn’t exist makes the directory invalid. The
adapter makes generated code use them; each file in conformance/externs/<target>/ documents its
target’s host shape.
TS: the module exports a value named exactly like the extern, in the shapes of IR.md §5
(export const rle = { decode, encode }, export function sum8(…), export const VarInt = { size, read, write }). Codec arguments arrive in ctx.args (u8 → number, u64 → bigint, u8[n] →
Uint8Array).
Rust (the host interface of packages/runtime-rust): the file becomes a module of the
generated crate and can use yaffle_runtime. The item is the extern’s name, or the #ITEM of the
mapping. Dependencies other than the runtime are listed in yaffle.json under
targets.rust.dependencies.
| Extern | Item |
|---|---|
extern fn f(T a, …) -> R | pub fn f(a: T, …) -> R (ints as their Rust type, u8[] → &[u8], char[] → &str) |
extern codec c(args) | a value implementing yaffle_runtime::Codec: decode(&self, input, &CodecCtx { args, output_size, position }), encode(…); arguments are yaffle_runtime::Args |
@streaming codec | also decode_streaming(&self, input, ctx) -> Result<(Vec<u8>, usize), String> (output, bytes consumed) |
async codec | the same trait, synchronous: an async codec gives the same bytes as its sync twin |
extern type X(args) : T | a value implementing yaffle_runtime::ExternType<Value = T>: size(&self, bytes, args) -> Option<usize>, read, write |
conformance/externs/rust/stub/yaffle_runtime.rs mirrors that interface so the extern files can be
unit-tested without the runtime (conformance/scripts/test-rust-externs.sh).
Errors returned by a codec are CODEC errors.
The conformance externs (conformance/externs/<target>/, one implementation per target):
| Extern | Behaviour |
|---|---|
extern codec xor(u8 key) @ranged | every byte XOR key |
extern codec xorpos(u8 key) @ranged | byte i XOR (key + i) & 0xff, i from the start of the region |
extern async codec axor(u8 key) | xor, asynchronous where the target has async codecs |
extern codec rle | (count 1..255, byte) pairs; greedy encoder; checks outputSize |
extern codec rlez @streaming | rle pairs then a 0x00 terminator |
extern fn sum8(u8 data[]) -> u8 | sum of the bytes mod 256 |
extern fn swap16(u16 v) -> u16 | byte swap |
extern type VarInt(u8 maxBytes) : u64 | unsigned LEB128 |
extern type Bcd16 : u16 @size(2) | four BCD digits, most significant first |
6. Running
| all conformance tests | npx vitest run --project conformance |
| runner and generator tests | npx vitest run --project conformance test/runner.test.ts test/units.test.ts test/generated.test.ts |
| pass/fail matrix | node --conditions=development conformance/src/cli.ts [--filter …] [--targets …] [-v] [--json] |
| against other implementations | … cli.ts --compiler <path/to/index.ts> --adapter <path/to/conformance.ts> (e.g. another worktree’s frontend or backend) |
| list / validate cases | node --conditions=development conformance/src/cli.ts --list / --validate |
| regenerate cases.json | node conformance/scripts/gen-cases.ts [<area>…] (--check to verify) |
| Rust extern unit tests | nix shell nixpkgs#rustc --command conformance/scripts/test-rust-externs.sh |
| Environment variable | Effect |
|---|---|
YAFFLE_CONFORMANCE_FILTER | comma-separated patterns on <dir>/<case name>: substrings, globs with *, ! excludes |
YAFFLE_CONFORMANCE_TARGETS | comma-separated targets (ts,rust); ts also selects ts:view, ts:view only the view |
YAFFLE_CONFORMANCE_VIEWS=0 | no view-mode targets (§7) |
The CLI’s --filter and --targets set the first two.
7. View mode
Adapters that declare capabilities: { views: true } are also run as a second target named
<target>:view (for example ts:view). In view mode the runner sends the same case directories
with mode: "view" and skips serialize-only cases. For every other case, the adapter must:
- open the target’s lazy view over the case bytes (the bytes, or a random-access source over the
file for
{ file }cases); - produce
jsonby reading every field through the view (materialize, then canonical JSON), not by callingparse; - produce
bytesby committing the view with no edits, which must reproduce the input exactly (the round-trip check), unlessroundtripisfalse; - report errors raised while reading through the view (same
codeandpathas parse mode).
The runner judges view results exactly like parse results. View mode exists because views have their own code paths (lazy offsets, decoded regions, streams) that parse-mode cases never exercise.