Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 + json and/or expect): toJson(parse(bytes)) must deep-equal json (§3), and every expect check must hold on it.
  • Round trips of parse cases:
    • true / "json" (the default): serialize(fromJson(json)) must equal bytes, where json is the actual canonical JSON (equal to the expected one when the case has it);
    • "value": serialize(parse(bytes)) must equal bytes in 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 equal bytes.
  • 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/length cut 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>" }. length is an array’s or string’s length, byteLength a 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.
  • @ref resolves 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.

ValueJSON
structobject, 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/i48number
56- and 64-bit integerdecimal string, e.g. "18446744073709551615", also when small ("1")
floatnumber. NaN, ±Infinity and -0 are the strings "NaN", "Infinity", "-Infinity" and "-0".
booltrue / false
bytes (u8 arrays only)lowercase hex string; other element types are JSON arrays
stringstring
arrayarray
enummember name; an open unknown value follows the integer rule of the storage (a number, or a decimal string for 56/64-bit storage)
pointerthe 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": … }
transformthe transformed value’s JSON (one-way transforms: output only; the input is the raw value)
extern typeJSON of its value type
bit fieldper its declared type (u64 lo : 40 is a decimal string)
bound field u32 $versionthe 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

CodeMeaning
EOFnot enough bytes (including an extern type’s size asking for more at the end)
CHECKa check failed (constant, in, where, regex, strict padding or reserved bits, bool, enum, string encoding, switch without match), on read or on write
RANGEan integer out of range for its destination, division by zero, a negative length, an index out of range
UNIONno union variant matched
CODECa codec failed
INPUTinvalid 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
DERIVEa 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
REFa @ref pointer has no equal placed value
OVERLAPfixed 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.

ExternItem
extern fn f(T a, …) -> Rpub 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 codecalso decode_streaming(&self, input, ctx) -> Result<(Vec<u8>, usize), String> (output, bytes consumed)
async codecthe same trait, synchronous: an async codec gives the same bytes as its sync twin
extern type X(args) : Ta 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):

ExternBehaviour
extern codec xor(u8 key) @rangedevery byte XOR key
extern codec xorpos(u8 key) @rangedbyte 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 @streamingrle pairs then a 0x00 terminator
extern fn sum8(u8 data[]) -> u8sum of the bytes mod 256
extern fn swap16(u16 v) -> u16byte swap
extern type VarInt(u8 maxBytes) : u64unsigned LEB128
extern type Bcd16 : u16 @size(2)four BCD digits, most significant first

6. Running

all conformance testsnpx vitest run --project conformance
runner and generator testsnpx vitest run --project conformance test/runner.test.ts test/units.test.ts test/generated.test.ts
pass/fail matrixnode --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 casesnode --conditions=development conformance/src/cli.ts --list / --validate
regenerate cases.jsonnode conformance/scripts/gen-cases.ts [<area>…] (--check to verify)
Rust extern unit testsnix shell nixpkgs#rustc --command conformance/scripts/test-rust-externs.sh
Environment variableEffect
YAFFLE_CONFORMANCE_FILTERcomma-separated patterns on <dir>/<case name>: substrings, globs with *, ! excludes
YAFFLE_CONFORMANCE_TARGETScomma-separated targets (ts,rust); ts also selects ts:view, ts:view only the view
YAFFLE_CONFORMANCE_VIEWS=0no 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:

  1. open the target’s lazy view over the case bytes (the bytes, or a random-access source over the file for { file } cases);
  2. produce json by reading every field through the view (materialize, then canonical JSON), not by calling parse;
  3. produce bytes by committing the view with no edits, which must reproduce the input exactly (the round-trip check), unless roundtrip is false;
  4. report errors raised while reading through the view (same code and path as 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.