From f52e1c97f65ef3f5595939832ff2998441f1eb23 Mon Sep 17 00:00:00 2001 From: Nazar Hussain Date: Thu, 28 May 2026 12:56:07 +0200 Subject: [PATCH 01/21] chore(deps): add fast-check and test:fuzz script --- package.json | 2 ++ pnpm-lock.yaml | 16 ++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/package.json b/package.json index ba0c546..dff7a6b 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "build:zig": "zig build", "test:zig": "zig build test:zapi", "test:js": "vitest run examples/**/*.test.ts", + "test:fuzz": "vitest run tests/**/*.test.ts", "test": "pnpm test:zig && pnpm test:js", "lint:zig": "zig fmt src", "lint:js": "pnpm biome check", @@ -34,6 +35,7 @@ "@biomejs/biome": "^2.4.6", "@chainsafe/biomejs-config": "^1.0.0", "@types/node": "^24.1.0", + "fast-check": "^4.8.0", "typescript": "^5.8.3", "vitest": "^4.0.18" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e5affa3..a811fce 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@types/node': specifier: ^24.1.0 version: 24.10.10 + fast-check: + specifier: ^4.8.0 + version: 4.8.0 typescript: specifier: ^5.8.3 version: 5.9.3 @@ -435,6 +438,10 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + fast-check@4.8.0: + resolution: {integrity: sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg==} + engines: {node: '>=12.17.0'} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -474,6 +481,9 @@ packages: resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} engines: {node: ^10 || ^12 || >=14} + pure-rand@8.4.0: + resolution: {integrity: sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==} + rollup@4.59.0: resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -885,6 +895,10 @@ snapshots: expect-type@1.3.0: {} + fast-check@4.8.0: + dependencies: + pure-rand: 8.4.0 + fdir@6.5.0(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 @@ -912,6 +926,8 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + pure-rand@8.4.0: {} + rollup@4.59.0: dependencies: '@types/estree': 1.0.8 From ba7cca73f475e074b86b770420b6dbe648207487 Mon Sep 17 00:00:00 2001 From: Nazar Hussain Date: Thu, 28 May 2026 13:04:58 +0200 Subject: [PATCH 02/21] chore(tests): scaffold fuzz_numeric addon module Co-Authored-By: Claude Sonnet 4.6 --- build.zig.zon | 11 +++++++++++ tests/fuzz_numeric/mod.zig | 16 ++++++++++++++++ tests/fuzz_numeric/smoke.test.ts | 11 +++++++++++ 3 files changed, 38 insertions(+) create mode 100644 tests/fuzz_numeric/mod.zig create mode 100644 tests/fuzz_numeric/smoke.test.ts diff --git a/build.zig.zon b/build.zig.zon index d595321..be97229 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -49,6 +49,11 @@ .imports = .{.zapi}, .link_libc = true, }, + .test_fuzz_numeric = .{ + .root_source_file = "tests/fuzz_numeric/mod.zig", + .imports = .{.zapi}, + .link_libc = true, + }, }, .libraries = .{ .example_hello_world = .{ @@ -69,6 +74,12 @@ .linker_allow_shlib_undefined = true, .dest_sub_path = "example_js_dsl.node", }, + .test_fuzz_numeric = .{ + .root_module = .test_fuzz_numeric, + .linkage = .dynamic, + .linker_allow_shlib_undefined = true, + .dest_sub_path = "test_fuzz_numeric.node", + }, }, .tests = .{ .napi = .{ .root_module = .napi }, diff --git a/tests/fuzz_numeric/mod.zig b/tests/fuzz_numeric/mod.zig new file mode 100644 index 0000000..51568fe --- /dev/null +++ b/tests/fuzz_numeric/mod.zig @@ -0,0 +1,16 @@ +//! Fuzz harness addon: per-converter round-trip exports for numeric types. +//! +//! Each export is a single converter end-to-end (JS → Zig → JS). The JS-side +//! fast-check harness compares each result against an ECMAScript/NAPI-spec +//! oracle. See docs/superpowers/specs/2026-05-28-fuzz-testing-design.md. + +const js = @import("zapi").js; + +/// Smoke-test export: confirms the addon loads and exportModule wires through. +pub fn ping() js.Number { + return js.Number.from(@as(i32, 42)); +} + +comptime { + js.exportModule(@This(), .{}); +} diff --git a/tests/fuzz_numeric/smoke.test.ts b/tests/fuzz_numeric/smoke.test.ts new file mode 100644 index 0000000..2212449 --- /dev/null +++ b/tests/fuzz_numeric/smoke.test.ts @@ -0,0 +1,11 @@ +import { describe, it, expect } from "vitest"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const mod = require("../../zig-out/lib/test_fuzz_numeric.node"); + +describe("fuzz_numeric addon loads", () => { + it("exports a ping function returning 42", () => { + expect(mod.ping()).toEqual(42); + }); +}); From 5838156fb4d94d863c101358b515706d83253215 Mon Sep 17 00:00:00 2001 From: Nazar Hussain Date: Thu, 28 May 2026 13:17:02 +0200 Subject: [PATCH 03/21] test(fuzz): add rtNumberF64 round-trip fuzz target Co-Authored-By: Claude Sonnet 4.6 --- tests/fuzz_numeric/edges.ts | 32 ++++++++++++++++++++ tests/fuzz_numeric/fuzz.test.ts | 50 +++++++++++++++++++++++++++++++ tests/fuzz_numeric/mod.zig | 11 +++---- tests/fuzz_numeric/oracle.test.ts | 36 ++++++++++++++++++++++ tests/fuzz_numeric/seeds/.gitkeep | 0 tests/fuzz_numeric/smoke.test.ts | 11 ------- 6 files changed, 122 insertions(+), 18 deletions(-) create mode 100644 tests/fuzz_numeric/edges.ts create mode 100644 tests/fuzz_numeric/fuzz.test.ts create mode 100644 tests/fuzz_numeric/oracle.test.ts create mode 100644 tests/fuzz_numeric/seeds/.gitkeep delete mode 100644 tests/fuzz_numeric/smoke.test.ts diff --git a/tests/fuzz_numeric/edges.ts b/tests/fuzz_numeric/edges.ts new file mode 100644 index 0000000..ec14b1d --- /dev/null +++ b/tests/fuzz_numeric/edges.ts @@ -0,0 +1,32 @@ +/** + * Hand-curated edge values that any decent numeric converter should handle. + * Mixed into fast-check generators via fc.constantFrom(...). + * + * BigInt list is added incrementally as BigInt-targeting tasks land. + */ + +export const edgeNumbers: readonly number[] = [ + 0, + -0, + NaN, + Infinity, + -Infinity, + Number.MIN_VALUE, + -Number.MIN_VALUE, + Number.MAX_SAFE_INTEGER, + -Number.MAX_SAFE_INTEGER, + 2 ** 31, + -(2 ** 31), + 2 ** 31 - 1, + -(2 ** 31) - 1, + 2 ** 32, + 2 ** 32 - 1, + 2 ** 53, + 2 ** 53 + 1, + 2 ** 63, + -(2 ** 63), +]; + +export const edgeBigInts: readonly bigint[] = [ + // Filled in incrementally by BigInt tasks (5–8). +]; diff --git a/tests/fuzz_numeric/fuzz.test.ts b/tests/fuzz_numeric/fuzz.test.ts new file mode 100644 index 0000000..1a72181 --- /dev/null +++ b/tests/fuzz_numeric/fuzz.test.ts @@ -0,0 +1,50 @@ +import { describe, it } from "vitest"; +import { createRequire } from "node:module"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as url from "node:url"; +import fc from "fast-check"; +import { edgeNumbers } from "./edges.ts"; + +const require = createRequire(import.meta.url); +const mod = require("../../zig-out/lib/test_fuzz_numeric.node") as { + rtNumberF64(n: number): number; +}; + +const FUZZ_RUNS = Number(process.env.FUZZ_RUNS ?? 10_000); +const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); + +/** + * Load persisted regression cases for a target. + * + * Each file under seeds/ is a JSON array of values (in whatever JSON-encodable + * form makes sense for the target; bigints use `{"__bigint":"123"}`). + * Missing file → empty examples list; not an error. + */ +function loadSeeds(target: string, revive: (raw: unknown) => T): T[] { + const file = path.join(__dirname, "seeds", `${target}.json`); + if (!fs.existsSync(file)) return []; + const raw = JSON.parse(fs.readFileSync(file, "utf8")) as unknown[]; + return raw.map(revive); +} + +const numberArb = fc.oneof( + { arbitrary: fc.double(), weight: 4 }, + { arbitrary: fc.constantFrom(...edgeNumbers), weight: 1 }, +); + +describe("rtNumberF64", () => { + it("round-trip is identity for all JS numbers", () => { + fc.assert( + fc.property(numberArb, (n) => { + const result = mod.rtNumberF64(n); + if (Number.isNaN(n)) return Number.isNaN(result); + return Object.is(result, n); + }), + { + numRuns: FUZZ_RUNS, + examples: loadSeeds("rtNumberF64", (raw) => raw as number), + }, + ); + }); +}); diff --git a/tests/fuzz_numeric/mod.zig b/tests/fuzz_numeric/mod.zig index 51568fe..20aadf2 100644 --- a/tests/fuzz_numeric/mod.zig +++ b/tests/fuzz_numeric/mod.zig @@ -1,14 +1,11 @@ //! Fuzz harness addon: per-converter round-trip exports for numeric types. -//! -//! Each export is a single converter end-to-end (JS → Zig → JS). The JS-side -//! fast-check harness compares each result against an ECMAScript/NAPI-spec -//! oracle. See docs/superpowers/specs/2026-05-28-fuzz-testing-design.md. const js = @import("zapi").js; -/// Smoke-test export: confirms the addon loads and exportModule wires through. -pub fn ping() js.Number { - return js.Number.from(@as(i32, 42)); +/// Round-trip JS number → f64 → JS number. Oracle: identity for all finite +/// numbers, NaN ↔ NaN, ±0 preserved. +pub fn rtNumberF64(n: js.Number) !js.Number { + return js.Number.from(try n.toF64()); } comptime { diff --git a/tests/fuzz_numeric/oracle.test.ts b/tests/fuzz_numeric/oracle.test.ts new file mode 100644 index 0000000..196f142 --- /dev/null +++ b/tests/fuzz_numeric/oracle.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from "vitest"; +import { createRequire } from "node:module"; +import { edgeNumbers } from "./edges.ts"; + +const require = createRequire(import.meta.url); +const mod = require("../../zig-out/lib/test_fuzz_numeric.node") as { + rtNumberF64(n: number): number; +}; + +/** + * Oracle for rtNumberF64: identity over all finite JS numbers; NaN ↔ NaN; + * ±0 preserved (distinguished via Object.is). + */ +function oracleF64(value: number): number { + return value; +} + +function describe_value(v: number): string { + if (Number.isNaN(v)) return "NaN"; + if (Object.is(v, -0)) return "-0"; + return String(v); +} + +describe("oracle sanity: rtNumberF64", () => { + for (const v of edgeNumbers) { + it(`agrees with oracle on ${describe_value(v)}`, () => { + const expected = oracleF64(v); + const actual = mod.rtNumberF64(v); + if (Number.isNaN(expected)) { + expect(Number.isNaN(actual)).toBe(true); + } else { + expect(Object.is(actual, expected)).toBe(true); + } + }); + } +}); diff --git a/tests/fuzz_numeric/seeds/.gitkeep b/tests/fuzz_numeric/seeds/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/fuzz_numeric/smoke.test.ts b/tests/fuzz_numeric/smoke.test.ts deleted file mode 100644 index 2212449..0000000 --- a/tests/fuzz_numeric/smoke.test.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { createRequire } from "node:module"; - -const require = createRequire(import.meta.url); -const mod = require("../../zig-out/lib/test_fuzz_numeric.node"); - -describe("fuzz_numeric addon loads", () => { - it("exports a ping function returning 42", () => { - expect(mod.ping()).toEqual(42); - }); -}); From 189ed3acde16261bb27287bf37e15ad231ea9d44 Mon Sep 17 00:00:00 2001 From: Nazar Hussain Date: Thu, 28 May 2026 13:37:38 +0200 Subject: [PATCH 04/21] test(fuzz): add Number i32/u32/i64 round-trip properties Co-Authored-By: Claude Sonnet 4.6 --- tests/fuzz_numeric/fuzz.test.ts | 55 ++++++++++++++++++++++++++++ tests/fuzz_numeric/mod.zig | 16 ++++++++ tests/fuzz_numeric/oracle.test.ts | 61 ++++++++++++++++++++++++++++++- 3 files changed, 130 insertions(+), 2 deletions(-) diff --git a/tests/fuzz_numeric/fuzz.test.ts b/tests/fuzz_numeric/fuzz.test.ts index 1a72181..84f0fd0 100644 --- a/tests/fuzz_numeric/fuzz.test.ts +++ b/tests/fuzz_numeric/fuzz.test.ts @@ -9,6 +9,9 @@ import { edgeNumbers } from "./edges.ts"; const require = createRequire(import.meta.url); const mod = require("../../zig-out/lib/test_fuzz_numeric.node") as { rtNumberF64(n: number): number; + rtNumberI32(n: number): number; + rtNumberU32(n: number): number; + rtNumberI64(n: number): bigint; }; const FUZZ_RUNS = Number(process.env.FUZZ_RUNS ?? 10_000); @@ -48,3 +51,55 @@ describe("rtNumberF64", () => { ); }); }); + +const I64_MAX = (1n << 63n) - 1n; +const I64_MIN = -(1n << 63n); + +const numberIntArb = fc.oneof( + { arbitrary: fc.integer({ min: -(2 ** 33), max: 2 ** 33 }), weight: 3 }, + { arbitrary: fc.double(), weight: 2 }, + { arbitrary: fc.constantFrom(...edgeNumbers), weight: 1 }, +); + +describe("rtNumberI32", () => { + it("matches ECMAScript ToInt32 (`| 0`)", () => { + fc.assert( + fc.property(numberIntArb, (n) => mod.rtNumberI32(n) === (n | 0)), + { + numRuns: FUZZ_RUNS, + examples: loadSeeds("rtNumberI32", (r) => r as number), + }, + ); + }); +}); + +describe("rtNumberU32", () => { + it("matches ECMAScript ToUint32 (`>>> 0`)", () => { + fc.assert( + fc.property(numberIntArb, (n) => mod.rtNumberU32(n) === (n >>> 0)), + { + numRuns: FUZZ_RUNS, + examples: loadSeeds("rtNumberU32", (r) => r as number), + }, + ); + }); +}); + +describe("rtNumberI64", () => { + it("matches NAPI int64 semantics (clamped)", () => { + fc.assert( + fc.property(numberIntArb, (n) => { + let expected: bigint; + if (Number.isNaN(n) || !Number.isFinite(n)) expected = 0n; + else if (n >= 2 ** 63) expected = I64_MAX; + else if (n < -(2 ** 63)) expected = I64_MIN; + else expected = BigInt(Math.trunc(n)); + return mod.rtNumberI64(n) === expected; + }), + { + numRuns: FUZZ_RUNS, + examples: loadSeeds("rtNumberI64", (r) => r as number), + }, + ); + }); +}); diff --git a/tests/fuzz_numeric/mod.zig b/tests/fuzz_numeric/mod.zig index 20aadf2..92de895 100644 --- a/tests/fuzz_numeric/mod.zig +++ b/tests/fuzz_numeric/mod.zig @@ -8,6 +8,22 @@ pub fn rtNumberF64(n: js.Number) !js.Number { return js.Number.from(try n.toF64()); } +/// Round-trip via ToInt32 (`value | 0` semantics). +pub fn rtNumberI32(n: js.Number) !js.Number { + return js.Number.from(try n.toI32()); +} + +/// Round-trip via ToUint32 (`value >>> 0` semantics). +pub fn rtNumberU32(n: js.Number) !js.Number { + return js.Number.from(try n.toU32()); +} + +/// Round-trip via NAPI int64. Returns a JS BigInt so the result is lossless +/// in JS (a JS number cannot represent all of i64 exactly). +pub fn rtNumberI64(n: js.Number) !js.BigInt { + return js.BigInt.from(try n.toI64()); +} + comptime { js.exportModule(@This(), .{}); } diff --git a/tests/fuzz_numeric/oracle.test.ts b/tests/fuzz_numeric/oracle.test.ts index 196f142..a021bf5 100644 --- a/tests/fuzz_numeric/oracle.test.ts +++ b/tests/fuzz_numeric/oracle.test.ts @@ -5,6 +5,9 @@ import { edgeNumbers } from "./edges.ts"; const require = createRequire(import.meta.url); const mod = require("../../zig-out/lib/test_fuzz_numeric.node") as { rtNumberF64(n: number): number; + rtNumberI32(n: number): number; + rtNumberU32(n: number): number; + rtNumberI64(n: number): bigint; }; /** @@ -15,7 +18,7 @@ function oracleF64(value: number): number { return value; } -function describe_value(v: number): string { +function describeValue(v: number): string { if (Number.isNaN(v)) return "NaN"; if (Object.is(v, -0)) return "-0"; return String(v); @@ -23,7 +26,7 @@ function describe_value(v: number): string { describe("oracle sanity: rtNumberF64", () => { for (const v of edgeNumbers) { - it(`agrees with oracle on ${describe_value(v)}`, () => { + it(`agrees with oracle on ${describeValue(v)}`, () => { const expected = oracleF64(v); const actual = mod.rtNumberF64(v); if (Number.isNaN(expected)) { @@ -34,3 +37,57 @@ describe("oracle sanity: rtNumberF64", () => { }); } }); + +/** ECMAScript ToInt32: `value | 0`. NaN/±Inf → 0. */ +function oracleI32(value: number): number { + return value | 0; +} + +/** ECMAScript ToUint32: `value >>> 0`. NaN/±Inf → 0. */ +function oracleU32(value: number): number { + return value >>> 0; +} + +/** + * NAPI napi_get_value_int64 semantics (per Node.js NAPI docs): + * - NaN, ±Infinity → 0 + * - value >= 2^63 → INT64_MAX (clamped, not zero) + * - value < -2^63 → INT64_MIN (clamped) + * - exact -2^63 → INT64_MIN (representable, returned faithfully) + * - otherwise: BigInt(Math.trunc(value)) interpreted as i64 + * + * Returned as a JS bigint so the JS comparison stays lossless. + */ +const I64_MAX = (1n << 63n) - 1n; +const I64_MIN = -(1n << 63n); + +function oracleI64(value: number): bigint { + if (Number.isNaN(value) || !Number.isFinite(value)) return 0n; + if (value >= 2 ** 63) return I64_MAX; + if (value < -(2 ** 63)) return I64_MIN; + return BigInt(Math.trunc(value)); +} + +describe("oracle sanity: rtNumberI32", () => { + for (const v of edgeNumbers) { + it(`agrees with oracle on ${describeValue(v)}`, () => { + expect(mod.rtNumberI32(v)).toBe(oracleI32(v)); + }); + } +}); + +describe("oracle sanity: rtNumberU32", () => { + for (const v of edgeNumbers) { + it(`agrees with oracle on ${describeValue(v)}`, () => { + expect(mod.rtNumberU32(v)).toBe(oracleU32(v)); + }); + } +}); + +describe("oracle sanity: rtNumberI64", () => { + for (const v of edgeNumbers) { + it(`agrees with oracle on ${describeValue(v)}`, () => { + expect(mod.rtNumberI64(v)).toBe(oracleI64(v)); + }); + } +}); From 0f1fa5619f2839ebc78ca38c355731321b812f31 Mon Sep 17 00:00:00 2001 From: Nazar Hussain Date: Thu, 28 May 2026 13:41:35 +0200 Subject: [PATCH 05/21] test(fuzz): add BigInt i64/u64 round-trip properties --- tests/fuzz_numeric/edges.ts | 11 +++++++- tests/fuzz_numeric/fuzz.test.ts | 44 ++++++++++++++++++++++++++++++- tests/fuzz_numeric/mod.zig | 14 ++++++++++ tests/fuzz_numeric/oracle.test.ts | 30 ++++++++++++++++++++- 4 files changed, 96 insertions(+), 3 deletions(-) diff --git a/tests/fuzz_numeric/edges.ts b/tests/fuzz_numeric/edges.ts index ec14b1d..5822ecf 100644 --- a/tests/fuzz_numeric/edges.ts +++ b/tests/fuzz_numeric/edges.ts @@ -28,5 +28,14 @@ export const edgeNumbers: readonly number[] = [ ]; export const edgeBigInts: readonly bigint[] = [ - // Filled in incrementally by BigInt tasks (5–8). + 0n, + 1n, + -1n, + 1n << 63n, + -(1n << 63n), + (1n << 63n) - 1n, + -((1n << 63n) - 1n), + 1n << 64n, + (1n << 64n) - 1n, + -(1n << 64n), ]; diff --git a/tests/fuzz_numeric/fuzz.test.ts b/tests/fuzz_numeric/fuzz.test.ts index 84f0fd0..64dc0ce 100644 --- a/tests/fuzz_numeric/fuzz.test.ts +++ b/tests/fuzz_numeric/fuzz.test.ts @@ -4,7 +4,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; import * as url from "node:url"; import fc from "fast-check"; -import { edgeNumbers } from "./edges.ts"; +import { edgeNumbers, edgeBigInts } from "./edges.ts"; const require = createRequire(import.meta.url); const mod = require("../../zig-out/lib/test_fuzz_numeric.node") as { @@ -12,6 +12,8 @@ const mod = require("../../zig-out/lib/test_fuzz_numeric.node") as { rtNumberI32(n: number): number; rtNumberU32(n: number): number; rtNumberI64(n: number): bigint; + rtBigIntI64(b: bigint): bigint; + rtBigIntU64(b: bigint): bigint; }; const FUZZ_RUNS = Number(process.env.FUZZ_RUNS ?? 10_000); @@ -31,6 +33,13 @@ function loadSeeds(target: string, revive: (raw: unknown) => T): T[] { return raw.map(revive); } +function reviveBigInt(raw: unknown): bigint { + if (typeof raw === "object" && raw !== null && "__bigint" in raw) { + return BigInt((raw as { __bigint: string }).__bigint); + } + throw new Error(`Cannot revive as bigint: ${JSON.stringify(raw)}`); +} + const numberArb = fc.oneof( { arbitrary: fc.double(), weight: 4 }, { arbitrary: fc.constantFrom(...edgeNumbers), weight: 1 }, @@ -103,3 +112,36 @@ describe("rtNumberI64", () => { ); }); }); + +const bigIntArbI64 = fc.oneof( + { arbitrary: fc.bigInt({ min: -(2n ** 65n), max: 2n ** 65n }), weight: 4 }, + { arbitrary: fc.constantFrom(...edgeBigInts), weight: 1 }, +); + +describe("rtBigIntI64", () => { + it("matches BigInt.asIntN(64, ·)", () => { + fc.assert( + fc.property(bigIntArbI64, (b) => + mod.rtBigIntI64(b) === BigInt.asIntN(64, b), + ), + { + numRuns: FUZZ_RUNS, + examples: loadSeeds("rtBigIntI64", reviveBigInt), + }, + ); + }); +}); + +describe("rtBigIntU64", () => { + it("matches BigInt.asUintN(64, ·)", () => { + fc.assert( + fc.property(bigIntArbI64, (b) => + mod.rtBigIntU64(b) === BigInt.asUintN(64, b), + ), + { + numRuns: FUZZ_RUNS, + examples: loadSeeds("rtBigIntU64", reviveBigInt), + }, + ); + }); +}); diff --git a/tests/fuzz_numeric/mod.zig b/tests/fuzz_numeric/mod.zig index 92de895..058299e 100644 --- a/tests/fuzz_numeric/mod.zig +++ b/tests/fuzz_numeric/mod.zig @@ -24,6 +24,20 @@ pub fn rtNumberI64(n: js.Number) !js.BigInt { return js.BigInt.from(try n.toI64()); } +/// Round-trip via NAPI int64. The `lossless` flag is discarded; the result +/// equals BigInt.asIntN(64, b) regardless. See `losslessI64` (Task 6) for +/// flag-exposing variants. +pub fn rtBigIntI64(b: js.BigInt) !js.BigInt { + var lossless: bool = false; + return js.BigInt.from(try b.toI64(&lossless)); +} + +/// Round-trip via NAPI uint64. Result equals BigInt.asUintN(64, b). +pub fn rtBigIntU64(b: js.BigInt) !js.BigInt { + var lossless: bool = false; + return js.BigInt.from(try b.toU64(&lossless)); +} + comptime { js.exportModule(@This(), .{}); } diff --git a/tests/fuzz_numeric/oracle.test.ts b/tests/fuzz_numeric/oracle.test.ts index a021bf5..bd095b9 100644 --- a/tests/fuzz_numeric/oracle.test.ts +++ b/tests/fuzz_numeric/oracle.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest"; import { createRequire } from "node:module"; -import { edgeNumbers } from "./edges.ts"; +import { edgeNumbers, edgeBigInts } from "./edges.ts"; const require = createRequire(import.meta.url); const mod = require("../../zig-out/lib/test_fuzz_numeric.node") as { @@ -8,6 +8,8 @@ const mod = require("../../zig-out/lib/test_fuzz_numeric.node") as { rtNumberI32(n: number): number; rtNumberU32(n: number): number; rtNumberI64(n: number): bigint; + rtBigIntI64(b: bigint): bigint; + rtBigIntU64(b: bigint): bigint; }; /** @@ -91,3 +93,29 @@ describe("oracle sanity: rtNumberI64", () => { }); } }); + +/** Two's-complement low 64 bits, signed. */ +function oracleBigIntI64(b: bigint): bigint { + return BigInt.asIntN(64, b); +} + +/** Low 64 bits, unsigned. */ +function oracleBigIntU64(b: bigint): bigint { + return BigInt.asUintN(64, b); +} + +describe("oracle sanity: rtBigIntI64", () => { + for (const b of edgeBigInts) { + it(`agrees with oracle on ${b}n`, () => { + expect(mod.rtBigIntI64(b)).toBe(oracleBigIntI64(b)); + }); + } +}); + +describe("oracle sanity: rtBigIntU64", () => { + for (const b of edgeBigInts) { + it(`agrees with oracle on ${b}n`, () => { + expect(mod.rtBigIntU64(b)).toBe(oracleBigIntU64(b)); + }); + } +}); From 76df5ca9ad544707038e94413901dc58e76cee61 Mon Sep 17 00:00:00 2001 From: Nazar Hussain Date: Thu, 28 May 2026 16:49:51 +0200 Subject: [PATCH 06/21] test(fuzz): add lossless-flag introspection for toI64/toU64 --- tests/fuzz_numeric/fuzz.test.ts | 34 +++++++++++++++++++++++++++++++ tests/fuzz_numeric/mod.zig | 25 +++++++++++++++++++++++ tests/fuzz_numeric/oracle.test.ts | 31 ++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+) diff --git a/tests/fuzz_numeric/fuzz.test.ts b/tests/fuzz_numeric/fuzz.test.ts index 64dc0ce..c8a9030 100644 --- a/tests/fuzz_numeric/fuzz.test.ts +++ b/tests/fuzz_numeric/fuzz.test.ts @@ -14,6 +14,8 @@ const mod = require("../../zig-out/lib/test_fuzz_numeric.node") as { rtNumberI64(n: number): bigint; rtBigIntI64(b: bigint): bigint; rtBigIntU64(b: bigint): bigint; + losslessI64(b: bigint): { value: bigint; lossless: boolean }; + losslessU64(b: bigint): { value: bigint; lossless: boolean }; }; const FUZZ_RUNS = Number(process.env.FUZZ_RUNS ?? 10_000); @@ -145,3 +147,35 @@ describe("rtBigIntU64", () => { ); }); }); + +describe("losslessI64", () => { + it("lossless ⇔ b ∈ [-2^63, 2^63) and value matches asIntN", () => { + fc.assert( + fc.property(bigIntArbI64, (b) => { + const { value, lossless } = mod.losslessI64(b); + const inRange = b >= -(1n << 63n) && b < 1n << 63n; + return value === BigInt.asIntN(64, b) && lossless === inRange; + }), + { + numRuns: FUZZ_RUNS, + examples: loadSeeds("losslessI64", reviveBigInt), + }, + ); + }); +}); + +describe("losslessU64", () => { + it("lossless ⇔ b ∈ [0, 2^64) and value matches asUintN", () => { + fc.assert( + fc.property(bigIntArbI64, (b) => { + const { value, lossless } = mod.losslessU64(b); + const inRange = b >= 0n && b < 1n << 64n; + return value === BigInt.asUintN(64, b) && lossless === inRange; + }), + { + numRuns: FUZZ_RUNS, + examples: loadSeeds("losslessU64", reviveBigInt), + }, + ); + }); +}); diff --git a/tests/fuzz_numeric/mod.zig b/tests/fuzz_numeric/mod.zig index 058299e..39c9415 100644 --- a/tests/fuzz_numeric/mod.zig +++ b/tests/fuzz_numeric/mod.zig @@ -38,6 +38,31 @@ pub fn rtBigIntU64(b: js.BigInt) !js.BigInt { return js.BigInt.from(try b.toU64(&lossless)); } +/// Returns `{ value: BigInt, lossless: Boolean }` exposing toI64's lossless +/// out-parameter to the fuzzer. +pub fn losslessI64(b: js.BigInt) !js.Value { + var lossless: bool = false; + const v = try b.toI64(&lossless); + const value = js.BigInt.from(v); + const flag = js.Boolean.from(lossless); + const obj = try js.env().createObject(); + try obj.setNamedProperty("value", value.toValue()); + try obj.setNamedProperty("lossless", flag.toValue()); + return .{ .val = obj }; +} + +/// Returns `{ value: BigInt, lossless: Boolean }` for toU64. +pub fn losslessU64(b: js.BigInt) !js.Value { + var lossless: bool = false; + const v = try b.toU64(&lossless); + const value = js.BigInt.from(v); + const flag = js.Boolean.from(lossless); + const obj = try js.env().createObject(); + try obj.setNamedProperty("value", value.toValue()); + try obj.setNamedProperty("lossless", flag.toValue()); + return .{ .val = obj }; +} + comptime { js.exportModule(@This(), .{}); } diff --git a/tests/fuzz_numeric/oracle.test.ts b/tests/fuzz_numeric/oracle.test.ts index bd095b9..5c7604a 100644 --- a/tests/fuzz_numeric/oracle.test.ts +++ b/tests/fuzz_numeric/oracle.test.ts @@ -119,3 +119,34 @@ describe("oracle sanity: rtBigIntU64", () => { }); } }); + +const modL = require("../../zig-out/lib/test_fuzz_numeric.node") as { + losslessI64(b: bigint): { value: bigint; lossless: boolean }; + losslessU64(b: bigint): { value: bigint; lossless: boolean }; +}; + +function expectedLosslessI64(b: bigint): { value: bigint; lossless: boolean } { + const losslessRange = b >= -(1n << 63n) && b < 1n << 63n; + return { value: BigInt.asIntN(64, b), lossless: losslessRange }; +} + +function expectedLosslessU64(b: bigint): { value: bigint; lossless: boolean } { + const losslessRange = b >= 0n && b < 1n << 64n; + return { value: BigInt.asUintN(64, b), lossless: losslessRange }; +} + +describe("oracle sanity: losslessI64", () => { + for (const b of edgeBigInts) { + it(`agrees with oracle on ${b}n`, () => { + expect(modL.losslessI64(b)).toEqual(expectedLosslessI64(b)); + }); + } +}); + +describe("oracle sanity: losslessU64", () => { + for (const b of edgeBigInts) { + it(`agrees with oracle on ${b}n`, () => { + expect(modL.losslessU64(b)).toEqual(expectedLosslessU64(b)); + }); + } +}); From 6c2cfedba9b448de7e25aafcafe9a344502c698e Mon Sep 17 00:00:00 2001 From: Nazar Hussain Date: Thu, 28 May 2026 17:09:08 +0200 Subject: [PATCH 07/21] fix(napi): clamp getValueBigintWords return slice to buffer size NAPI sets word_count to the required size even when the buffer is smaller than needed; the previous code returned words[0..word_count], which is UB when word_count > words.len. Clamping via @min makes the function safe and gives callers the semantics of truncate-to-low-N-bits. --- src/Value.zig | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Value.zig b/src/Value.zig index bd3b206..7c2157f 100644 --- a/src/Value.zig +++ b/src/Value.zig @@ -235,7 +235,11 @@ pub fn getValueBigintWords(self: Value, sign_bit: ?*u1, words: []u64) NapiError! ); // napi guarantees raw_sign ∈ {0, 1} if (sign_bit) |s| s.* = @intCast(raw_sign); - return words[0..word_count]; + // `word_count` is set by NAPI to the actual number of 64-bit words in the + // BigInt, which may exceed `words.len` when the value is larger than the + // buffer. NAPI fills `words` up to `words.len`; clamp the returned slice + // so callers get only the words that were actually written. + return words[0..@min(word_count, words.len)]; } /// https://nodejs.org/api/n-api.html#napi_get_value_external From 6f9bf721345998f63d3ab41f3870c27cf69601ca Mon Sep 17 00:00:00 2001 From: Nazar Hussain Date: Thu, 28 May 2026 17:09:56 +0200 Subject: [PATCH 08/21] fix(dsl): make BigInt.toI128 total over all BigInts Previous toI128 panicked on three input classes: zero (result[0] on empty slice), minInt(i128) (@intCast overflow during negation), and out-of-range magnitudes. Reworks the function to use a pre-zeroed local buffer and wrapping arithmetic, returning BigInt.asIntN(128, b) semantics for out-of-domain inputs. Documents the in-domain (exact) and out-of-domain (truncation) contract in the doc comment, and adds an explicit comment on the magnitude == 0 guard for out-of-range negatives whose low 128 bits are all zero (e.g. -2^128n), where asIntN(128, b) === 0n. --- src/js/bigint.zig | 37 +++++++++++++++++++++++++++++-------- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/src/js/bigint.zig b/src/js/bigint.zig index fd38ebd..5a5fdff 100644 --- a/src/js/bigint.zig +++ b/src/js/bigint.zig @@ -36,22 +36,43 @@ pub const BigInt = struct { /// Attempts to convert the JavaScript BigInt to a Zig `i128`. /// - /// This function reads the BigInt as two 64-bit words and reconstructs it - /// into a Zig `i128`. It handles both positive and negative BigInts. + /// In-domain (`b ∈ [-2^127, 2^127)`): returns `b` exactly. + /// + /// Out-of-domain: returns `BigInt.asIntN(128, b)`, i.e. the low 128 bits + /// of the magnitude reinterpreted as a signed i128. This matches the + /// ECMAScript `BigInt.asIntN` semantics defined at + /// https://tc39.es/ecma262/#sec-bigint.asintn. + /// /// Returns an error if N-API operations fail. pub fn toI128(self: BigInt) !i128 { var sign_bit: u1 = 0; + // Pre-zeroed: NAPI writes only as many words as the BigInt has; unused + // words stay 0. When the value is 0n NAPI returns word_count == 0, so + // both words[0] and words[1] remain 0 — magnitude correctly becomes 0. + // When the BigInt exceeds 128 bits, getValueBigintWords fills only the + // two lower words (truncation to low 128 bits), giving BigInt.asIntN(128) + // semantics for out-of-range values. var words: [2]u64 = .{ 0, 0 }; - const result = try self.val.getValueBigintWords(&sign_bit, &words); - const lo: u128 = result[0]; - const hi: u128 = if (result.len > 1) result[1] else 0; + _ = try self.val.getValueBigintWords(&sign_bit, &words); + const lo: u128 = words[0]; + const hi: u128 = words[1]; const magnitude: u128 = (hi << 64) | lo; if (sign_bit == 1) { - // Negative: negate the magnitude + // Negative: result = -magnitude interpreted as i128. + // Use wrapping subtraction + bitcast to handle both minInt(i128) + // (magnitude == 2^127, which doesn't fit in i128 as positive) and + // out-of-range values (magnitude > 2^127, truncated to low 128 bits). + // Guard: out-of-range negatives whose low 128 bits are all zero + // (e.g. -2^128n → words [0, 0]) give magnitude == 0. asIntN(128) + // of such values is 0n; return early rather than relying on the + // `0 -% 0 == 0` coincidence below. if (magnitude == 0) return 0; - return -@as(i128, @intCast(magnitude)); + return @bitCast(0 -% magnitude); } - return @intCast(magnitude); + // Positive: bitcast u128 → i128 gives BigInt.asIntN(128) semantics. + // In-range values have magnitude ≤ 2^127-1 so the sign bit is clear; + // out-of-range values have the sign bit set, matching JS asIntN(128). + return @bitCast(magnitude); } /// Converts the JavaScript BigInt to a Zig `i64`, panicking on failure. From c3e12a1a95d70c6ce9ff34f8cb09419b176712bc Mon Sep 17 00:00:00 2001 From: Nazar Hussain Date: Thu, 28 May 2026 17:10:11 +0200 Subject: [PATCH 09/21] feat(dsl): add BigInt.fromWords for word-level construction Thin DSL wrapper over napi_create_bigint_words. Required for round-tripping i128 and larger values through the DSL without going through the i64/u64 path. Accepts a sign bit and a little-endian u64-word slice; the resulting BigInt equals (sign == 1 ? -1 : 1) * sum(words[i] << (64 * i)). --- src/js/bigint.zig | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/js/bigint.zig b/src/js/bigint.zig index 5a5fdff..0b2e3c3 100644 --- a/src/js/bigint.zig +++ b/src/js/bigint.zig @@ -136,6 +136,23 @@ pub const BigInt = struct { return .{ .val = val }; } + /// Creates a JavaScript `BigInt` from a sign bit and word array. + /// + /// `sign_bit` is 0 for non-negative, 1 for negative. `words` is the + /// little-endian unsigned magnitude. The resulting BigInt equals + /// `(sign_bit == 1 ? -1 : 1) * sum(words[i] << (64 * i))`. + /// + /// Use this for constructing BigInts whose magnitude exceeds `u64` (e.g., + /// i128 or larger). For `i64`/`u64` values, prefer `BigInt.from`. + /// + /// Panics if N-API operations fail (e.g., invalid environment). + pub fn fromWords(sign_bit: u1, words: []const u64) BigInt { + const e = context.env(); + const val = e.createBigintWords(sign_bit, words) catch + @panic("BigInt.fromWords: createBigintWords failed"); + return .{ .val = val }; + } + /// Returns the underlying `napi.Value` representation of this JavaScript BigInt. pub fn toValue(self: BigInt) napi.Value { return self.val; From 7f7d26e2e59b0ff49de0f0b936601df3b2bcf845 Mon Sep 17 00:00:00 2001 From: Nazar Hussain Date: Thu, 28 May 2026 17:10:46 +0200 Subject: [PATCH 10/21] test(fuzz): add rtBigIntI128 round-trip target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-trips JS BigInt → i128 → JS BigInt via toI128 and fromWords. Property test covers the i128 boundary plus out-of-range truncation via BigInt.asIntN(128, ·). Oracle test adds deterministic boundary cases for zero, I128_MIN/MAX (exact), just-out-of-range ±1, and oversized values whose low 128 bits are all zero (expected result: 0n). --- tests/fuzz_numeric/edges.ts | 4 +++ tests/fuzz_numeric/fuzz.test.ts | 26 +++++++++++++++ tests/fuzz_numeric/mod.zig | 20 ++++++++++++ tests/fuzz_numeric/oracle.test.ts | 53 +++++++++++++++++++++++++++++++ 4 files changed, 103 insertions(+) diff --git a/tests/fuzz_numeric/edges.ts b/tests/fuzz_numeric/edges.ts index 5822ecf..3547ff5 100644 --- a/tests/fuzz_numeric/edges.ts +++ b/tests/fuzz_numeric/edges.ts @@ -38,4 +38,8 @@ export const edgeBigInts: readonly bigint[] = [ 1n << 64n, (1n << 64n) - 1n, -(1n << 64n), + // i128 boundary + (1n << 127n) - 1n, + -(1n << 127n), + -((1n << 127n) - 1n), ]; diff --git a/tests/fuzz_numeric/fuzz.test.ts b/tests/fuzz_numeric/fuzz.test.ts index c8a9030..dfd37fd 100644 --- a/tests/fuzz_numeric/fuzz.test.ts +++ b/tests/fuzz_numeric/fuzz.test.ts @@ -16,6 +16,7 @@ const mod = require("../../zig-out/lib/test_fuzz_numeric.node") as { rtBigIntU64(b: bigint): bigint; losslessI64(b: bigint): { value: bigint; lossless: boolean }; losslessU64(b: bigint): { value: bigint; lossless: boolean }; + rtBigIntI128(b: bigint): bigint; }; const FUZZ_RUNS = Number(process.env.FUZZ_RUNS ?? 10_000); @@ -179,3 +180,28 @@ describe("losslessU64", () => { ); }); }); + +const bigIntArbI128 = fc.oneof( + { arbitrary: fc.bigInt({ min: -(2n ** 129n), max: 2n ** 129n }), weight: 4 }, + { arbitrary: fc.constantFrom(...edgeBigInts), weight: 1 }, +); + +const I128_MIN = -(1n << 127n); +const I128_MAX = (1n << 127n) - 1n; + +describe("rtBigIntI128", () => { + it("identity in [-2^127, 2^127); BigInt.asIntN(128, ·) elsewhere", () => { + fc.assert( + fc.property(bigIntArbI128, (b) => { + const result = mod.rtBigIntI128(b); + const inRange = b >= I128_MIN && b <= I128_MAX; + const expected = inRange ? b : BigInt.asIntN(128, b); + return result === expected; + }), + { + numRuns: FUZZ_RUNS, + examples: loadSeeds("rtBigIntI128", reviveBigInt), + }, + ); + }); +}); diff --git a/tests/fuzz_numeric/mod.zig b/tests/fuzz_numeric/mod.zig index 39c9415..a8a718f 100644 --- a/tests/fuzz_numeric/mod.zig +++ b/tests/fuzz_numeric/mod.zig @@ -1,5 +1,6 @@ //! Fuzz harness addon: per-converter round-trip exports for numeric types. +const std = @import("std"); const js = @import("zapi").js; /// Round-trip JS number → f64 → JS number. Oracle: identity for all finite @@ -63,6 +64,25 @@ pub fn losslessU64(b: js.BigInt) !js.Value { return .{ .val = obj }; } +/// Round-trip JS BigInt → i128 → JS BigInt via fromWords. +/// +/// In-domain (b ∈ [-2^127, 2^127)) this is identity. Out-of-domain behavior +/// is determined by `BigInt.toI128` (currently: truncates to low 128 bits). +/// The fuzzer surfaces any mismatch against the oracle in fuzz.test.ts. +pub fn rtBigIntI128(b: js.BigInt) !js.BigInt { + const v = try b.toI128(); + const is_negative = v < 0; + const magnitude: u128 = if (is_negative) + @as(u128, @intCast(-(v + 1))) + 1 // safe for i128.minInt + else + @as(u128, @intCast(v)); + const words = [_]u64{ + @truncate(magnitude), + @truncate(magnitude >> 64), + }; + return js.BigInt.fromWords(if (is_negative) 1 else 0, &words); +} + comptime { js.exportModule(@This(), .{}); } diff --git a/tests/fuzz_numeric/oracle.test.ts b/tests/fuzz_numeric/oracle.test.ts index 5c7604a..d6d1133 100644 --- a/tests/fuzz_numeric/oracle.test.ts +++ b/tests/fuzz_numeric/oracle.test.ts @@ -150,3 +150,56 @@ describe("oracle sanity: losslessU64", () => { }); } }); + +const modI128 = require("../../zig-out/lib/test_fuzz_numeric.node") as { + rtBigIntI128(b: bigint): bigint; +}; + +const I128_MIN = -(1n << 127n); +const I128_MAX = (1n << 127n) - 1n; + +describe("oracle sanity: rtBigIntI128", () => { + for (const b of edgeBigInts) { + // Only assert identity in-range. Out-of-range edges are out of scope + // for the oracle test — the property test handles them via the + // implementation-defined policy (see fuzz.test.ts). + if (b < I128_MIN || b > I128_MAX) continue; + it(`round-trips ${b}n`, () => { + expect(modI128.rtBigIntI128(b)).toBe(b); + }); + } +}); + +describe("rtBigIntI128 boundary cases", () => { + it("0n → 0n (zero)", () => { + expect(modI128.rtBigIntI128(0n)).toBe(0n); + }); + + it("I128_MIN = -(1n << 127n) → -(1n << 127n) (in-range lower bound)", () => { + const b = -(1n << 127n); + expect(modI128.rtBigIntI128(b)).toBe(b); + }); + + it("I128_MAX = (1n << 127n) - 1n → (1n << 127n) - 1n (in-range upper bound)", () => { + const b = (1n << 127n) - 1n; + expect(modI128.rtBigIntI128(b)).toBe(b); + }); + + it("1n << 127n (just out-of-range positive) → BigInt.asIntN(128, 1n << 127n) === -(1n << 127n)", () => { + const b = 1n << 127n; + expect(modI128.rtBigIntI128(b)).toBe(BigInt.asIntN(128, b)); + }); + + it("-(1n << 127n) - 1n (just out-of-range negative) → BigInt.asIntN(128, -(1n << 127n) - 1n)", () => { + const b = -(1n << 127n) - 1n; + expect(modI128.rtBigIntI128(b)).toBe(BigInt.asIntN(128, b)); + }); + + it("1n << 128n (oversized positive, low 128 bits zero) → 0n", () => { + expect(modI128.rtBigIntI128(1n << 128n)).toBe(0n); + }); + + it("-(1n << 128n) (oversized negative, low 128 bits zero) → 0n", () => { + expect(modI128.rtBigIntI128(-(1n << 128n))).toBe(0n); + }); +}); From 34db2415a6f5136e158979c196e66b383b87dea0 Mon Sep 17 00:00:00 2001 From: Nazar Hussain Date: Thu, 28 May 2026 17:00:47 +0200 Subject: [PATCH 11/21] chore: apply zig fmt reorder to @ptrCast(@alignCast) Zig 0.16's `zig fmt` reorders `@alignCast(@ptrCast(x))` to `@ptrCast(@alignCast(x))`. The pre-existing form was triggering format churn in working trees across every `zig build` invocation; applying the canonical order once stops the noise. --- src/create_callback.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/create_callback.zig b/src/create_callback.zig index a8f9575..796b1ae 100644 --- a/src/create_callback.zig +++ b/src/create_callback.zig @@ -38,7 +38,7 @@ pub fn createCallback( const is_value_arg = arg_hint == .value or (arg_hint == .auto and param_type == Value); if (arg_hint == .data) { - args[i] = @alignCast(@ptrCast(info.data)); + args[i] = @ptrCast(@alignCast(info.data)); } else if (is_env_arg) { args[i] = env; } else if (is_value_arg) { From 3fd5552042eedd76192398bf91ac6c806bf95ec2 Mon Sep 17 00:00:00 2001 From: Nazar Hussain Date: Thu, 28 May 2026 17:14:36 +0200 Subject: [PATCH 12/21] test(fuzz): add BigInt word-level round-trip --- tests/fuzz_numeric/edges.ts | 7 +++++++ tests/fuzz_numeric/fuzz.test.ts | 22 ++++++++++++++++++++++ tests/fuzz_numeric/mod.zig | 17 +++++++++++++++++ tests/fuzz_numeric/oracle.test.ts | 12 ++++++++++++ 4 files changed, 58 insertions(+) diff --git a/tests/fuzz_numeric/edges.ts b/tests/fuzz_numeric/edges.ts index 3547ff5..e393991 100644 --- a/tests/fuzz_numeric/edges.ts +++ b/tests/fuzz_numeric/edges.ts @@ -42,4 +42,11 @@ export const edgeBigInts: readonly bigint[] = [ (1n << 127n) - 1n, -(1n << 127n), -((1n << 127n) - 1n), + // multi-word (beyond i128) + (1n << 128n) - 1n, + -((1n << 128n) - 1n), + 1n << 128n, + 1n << 191n, + (1n << 192n) - 1n, + 1n << 256n, ]; diff --git a/tests/fuzz_numeric/fuzz.test.ts b/tests/fuzz_numeric/fuzz.test.ts index dfd37fd..43adaf9 100644 --- a/tests/fuzz_numeric/fuzz.test.ts +++ b/tests/fuzz_numeric/fuzz.test.ts @@ -17,6 +17,7 @@ const mod = require("../../zig-out/lib/test_fuzz_numeric.node") as { losslessI64(b: bigint): { value: bigint; lossless: boolean }; losslessU64(b: bigint): { value: bigint; lossless: boolean }; rtBigIntI128(b: bigint): bigint; + rtBigIntWords(b: bigint): bigint; }; const FUZZ_RUNS = Number(process.env.FUZZ_RUNS ?? 10_000); @@ -205,3 +206,24 @@ describe("rtBigIntI128", () => { ); }); }); + +const bigIntArbWords = fc.oneof( + // Keep within the addon's 64-word cap (4096 bits). + { + arbitrary: fc.bigInt({ min: -(2n ** 4000n), max: 2n ** 4000n }), + weight: 4, + }, + { arbitrary: fc.constantFrom(...edgeBigInts), weight: 1 }, +); + +describe("rtBigIntWords", () => { + it("is identity for any BigInt under the word-buffer cap", () => { + fc.assert( + fc.property(bigIntArbWords, (b) => mod.rtBigIntWords(b) === b), + { + numRuns: FUZZ_RUNS, + examples: loadSeeds("rtBigIntWords", reviveBigInt), + }, + ); + }); +}); diff --git a/tests/fuzz_numeric/mod.zig b/tests/fuzz_numeric/mod.zig index a8a718f..2a39e89 100644 --- a/tests/fuzz_numeric/mod.zig +++ b/tests/fuzz_numeric/mod.zig @@ -83,6 +83,23 @@ pub fn rtBigIntI128(b: js.BigInt) !js.BigInt { return js.BigInt.fromWords(if (is_negative) 1 else 0, &words); } +/// Round-trip via getValueBigintWords → createBigintWords (via fromWords). +/// Identity for any BigInt that fits in the addon's word buffer (64 words = +/// 4096 bits is the practical cap). +/// +/// Implementation notes: +/// - We pass a 64-word buffer to `getValueBigintWords`. The `src/Value.zig` +/// wrapper now clamps the returned slice to `@min(word_count, buffer.len)`, +/// so oversized BigInts silently truncate. The 64-word cap covers all +/// entries in `edgeBigInts` (the largest, `1n << 256n`, is 5 words). +/// - Sign bit and the populated word slice are forwarded to `fromWords`. +pub fn rtBigIntWords(b: js.BigInt) !js.BigInt { + var sign_bit: u1 = 0; + var buf: [64]u64 = @splat(0); + const slice = try b.toValue().getValueBigintWords(&sign_bit, &buf); + return js.BigInt.fromWords(sign_bit, slice); +} + comptime { js.exportModule(@This(), .{}); } diff --git a/tests/fuzz_numeric/oracle.test.ts b/tests/fuzz_numeric/oracle.test.ts index d6d1133..0506774 100644 --- a/tests/fuzz_numeric/oracle.test.ts +++ b/tests/fuzz_numeric/oracle.test.ts @@ -203,3 +203,15 @@ describe("rtBigIntI128 boundary cases", () => { expect(modI128.rtBigIntI128(-(1n << 128n))).toBe(0n); }); }); + +const modW = require("../../zig-out/lib/test_fuzz_numeric.node") as { + rtBigIntWords(b: bigint): bigint; +}; + +describe("oracle sanity: rtBigIntWords", () => { + for (const b of edgeBigInts) { + it(`round-trips ${b}n`, () => { + expect(modW.rtBigIntWords(b)).toBe(b); + }); + } +}); From 13456e0f41688ce3edfc46a7a45042550b448ddc Mon Sep 17 00:00:00 2001 From: Nazar Hussain Date: Thu, 28 May 2026 19:05:36 +0200 Subject: [PATCH 13/21] ci: add fuzz tests job --- .github/workflows/ci.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 247f256..3995bd8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,19 @@ jobs: - name: Test run: pnpm test:js + test-fuzz: + name: Fuzz Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: ./.github/actions/setup-env + - name: Build + run: pnpm build + - name: Fuzz + run: pnpm test:fuzz + env: + FUZZ_RUNS: "1000" + lint: name: Lint runs-on: ubuntu-latest From b3fcea4ddec3bc3673ebbf7b3957c961fff306bb Mon Sep 17 00:00:00 2001 From: Nazar Hussain Date: Thu, 28 May 2026 19:07:03 +0200 Subject: [PATCH 14/21] docs(fuzz): document seeds workflow and dev loop --- tests/fuzz_numeric/README.md | 62 ++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 tests/fuzz_numeric/README.md diff --git a/tests/fuzz_numeric/README.md b/tests/fuzz_numeric/README.md new file mode 100644 index 0000000..cbf8af3 --- /dev/null +++ b/tests/fuzz_numeric/README.md @@ -0,0 +1,62 @@ +# fuzz_numeric + +Property-based fuzz tests for zapi's Number and BigInt conversions. Driven from JS through the `test_fuzz_numeric.node` addon (built by `zig build`) using vitest + fast-check. + +See `docs/superpowers/specs/2026-05-28-fuzz-testing-design.md` for design rationale. + +## Running + +```bash +# Full run (10 000 cases per property) +pnpm test:fuzz + +# CI-equivalent (1 000 cases per property) +FUZZ_RUNS=1000 pnpm test:fuzz + +# Single property +pnpm vitest run tests/fuzz_numeric/fuzz.test.ts -t "rtBigIntI128" +``` + +## When a property fails + +fast-check shrinks the counterexample and prints something like: + +``` +Property failed after 47 tests +Counterexample: 170141183460469231731687303715884105728n +Shrunk 14 times to: 170141183460469231731687303715884105728n +Seed: 1234567890, Path: 0:0:1 +``` + +To reproduce locally, paste the seed into a temporary `it.only`: + +```ts +it.only("repro", () => { + fc.assert( + fc.property(bigIntArbI128, (b) => ... ), + { seed: 1234567890, path: "0:0:1", numRuns: 1 }, + ); +}); +``` + +Once fixed, **persist the counterexample** so it runs forever: + +1. Create or open `tests/fuzz_numeric/seeds/.json`. +2. Append the counterexample to the JSON array. For bigints, use: + ```json + { "__bigint": "170141183460469231731687303715884105728" } + ``` + For numbers, use a JSON number directly. Note that NaN/Infinity aren't representable in JSON; for those, use: + ```json + { "__special": "NaN" } | { "__special": "Infinity" } | { "__special": "-Infinity" } + ``` + (and extend the corresponding `reviveX` helper in `fuzz.test.ts` if you add a new special form). +3. Commit with the fix. + +## Adding a new fuzz target + +1. Add the round-trip export to `mod.zig`. +2. Add the oracle to `oracle.test.ts` and run it against every entry in the relevant edge list. +3. Add the fast-check property to `fuzz.test.ts`. +4. Update `edges.ts` if the target needs new edge values. +5. Run `pnpm test:fuzz` and watch it pass (or find a bug). From e597b91b5bad852546fb6220677984460b9a5121 Mon Sep 17 00:00:00 2001 From: Nazar Hussain Date: Fri, 29 May 2026 10:25:32 +0200 Subject: [PATCH 15/21] =?UTF-8?q?docs(fuzz):=20correct=20seeds=20README=20?= =?UTF-8?q?=E2=80=94=20drop=20unimplemented=20=5F=5Fspecial=20reviver?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The seeds-workflow doc claimed NaN/Infinity could be persisted via a {"__special": ...} encoding, but no corresponding reviver exists in fuzz.test.ts — following the README literally would silently corrupt test input. NaN/Infinity are already in edgeNumbers and exercised every run, so persisting them adds nothing. Trim the README to match reality. --- tests/fuzz_numeric/README.md | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/tests/fuzz_numeric/README.md b/tests/fuzz_numeric/README.md index cbf8af3..2c54884 100644 --- a/tests/fuzz_numeric/README.md +++ b/tests/fuzz_numeric/README.md @@ -46,11 +46,7 @@ Once fixed, **persist the counterexample** so it runs forever: ```json { "__bigint": "170141183460469231731687303715884105728" } ``` - For numbers, use a JSON number directly. Note that NaN/Infinity aren't representable in JSON; for those, use: - ```json - { "__special": "NaN" } | { "__special": "Infinity" } | { "__special": "-Infinity" } - ``` - (and extend the corresponding `reviveX` helper in `fuzz.test.ts` if you add a new special form). + For numbers, use a JSON number directly. NaN and ±Infinity aren't representable in JSON and are not currently supported as persisted seeds — those values are already in `edgeNumbers` and exercised on every run, so persisting them as regression cases adds nothing. If a future fuzz target needs persisted special-number seeds, extend `loadSeeds` and the relevant `revive*` helper at that time. 3. Commit with the fix. ## Adding a new fuzz target From 9ccafd2d5f5dfd40dae8397de5244d1fcd774180 Mon Sep 17 00:00:00 2001 From: Nazar Hussain Date: Fri, 29 May 2026 11:12:26 +0200 Subject: [PATCH 16/21] chore: restructure oracle code --- package.json | 2 +- tests/fuzz_numeric/fuzz.test.ts | 54 +++++++++---------- tests/fuzz_numeric/oracle.test.ts | 90 ++++++++----------------------- tests/fuzz_numeric/oracles.ts | 60 +++++++++++++++++++++ 4 files changed, 109 insertions(+), 97 deletions(-) create mode 100644 tests/fuzz_numeric/oracles.ts diff --git a/package.json b/package.json index dff7a6b..fc1be97 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "build:zig": "zig build", "test:zig": "zig build test:zapi", "test:js": "vitest run examples/**/*.test.ts", - "test:fuzz": "vitest run tests/**/*.test.ts", + "test:fuzz": "pnpm build:zig && vitest run tests/**/*.test.ts", "test": "pnpm test:zig && pnpm test:js", "lint:zig": "zig fmt src", "lint:js": "pnpm biome check", diff --git a/tests/fuzz_numeric/fuzz.test.ts b/tests/fuzz_numeric/fuzz.test.ts index 43adaf9..fa99647 100644 --- a/tests/fuzz_numeric/fuzz.test.ts +++ b/tests/fuzz_numeric/fuzz.test.ts @@ -5,6 +5,17 @@ import * as path from "node:path"; import * as url from "node:url"; import fc from "fast-check"; import { edgeNumbers, edgeBigInts } from "./edges.ts"; +import { + oracleBigIntI128, + oracleBigIntI64, + oracleBigIntU64, + oracleLosslessI64, + oracleLosslessU64, + oracleNumberF64, + oracleNumberI32, + oracleNumberI64, + oracleNumberU32, +} from "./oracles.ts"; const require = createRequire(import.meta.url); const mod = require("../../zig-out/lib/test_fuzz_numeric.node") as { @@ -54,8 +65,9 @@ describe("rtNumberF64", () => { fc.assert( fc.property(numberArb, (n) => { const result = mod.rtNumberF64(n); - if (Number.isNaN(n)) return Number.isNaN(result); - return Object.is(result, n); + const expected = oracleNumberF64(n); + if (Number.isNaN(expected)) return Number.isNaN(result); + return Object.is(result, expected); }), { numRuns: FUZZ_RUNS, @@ -65,9 +77,6 @@ describe("rtNumberF64", () => { }); }); -const I64_MAX = (1n << 63n) - 1n; -const I64_MIN = -(1n << 63n); - const numberIntArb = fc.oneof( { arbitrary: fc.integer({ min: -(2 ** 33), max: 2 ** 33 }), weight: 3 }, { arbitrary: fc.double(), weight: 2 }, @@ -77,7 +86,7 @@ const numberIntArb = fc.oneof( describe("rtNumberI32", () => { it("matches ECMAScript ToInt32 (`| 0`)", () => { fc.assert( - fc.property(numberIntArb, (n) => mod.rtNumberI32(n) === (n | 0)), + fc.property(numberIntArb, (n) => mod.rtNumberI32(n) === oracleNumberI32(n)), { numRuns: FUZZ_RUNS, examples: loadSeeds("rtNumberI32", (r) => r as number), @@ -89,7 +98,7 @@ describe("rtNumberI32", () => { describe("rtNumberU32", () => { it("matches ECMAScript ToUint32 (`>>> 0`)", () => { fc.assert( - fc.property(numberIntArb, (n) => mod.rtNumberU32(n) === (n >>> 0)), + fc.property(numberIntArb, (n) => mod.rtNumberU32(n) === oracleNumberU32(n)), { numRuns: FUZZ_RUNS, examples: loadSeeds("rtNumberU32", (r) => r as number), @@ -101,14 +110,10 @@ describe("rtNumberU32", () => { describe("rtNumberI64", () => { it("matches NAPI int64 semantics (clamped)", () => { fc.assert( - fc.property(numberIntArb, (n) => { - let expected: bigint; - if (Number.isNaN(n) || !Number.isFinite(n)) expected = 0n; - else if (n >= 2 ** 63) expected = I64_MAX; - else if (n < -(2 ** 63)) expected = I64_MIN; - else expected = BigInt(Math.trunc(n)); - return mod.rtNumberI64(n) === expected; - }), + fc.property( + numberIntArb, + (n) => mod.rtNumberI64(n) === oracleNumberI64(n), + ), { numRuns: FUZZ_RUNS, examples: loadSeeds("rtNumberI64", (r) => r as number), @@ -126,7 +131,7 @@ describe("rtBigIntI64", () => { it("matches BigInt.asIntN(64, ·)", () => { fc.assert( fc.property(bigIntArbI64, (b) => - mod.rtBigIntI64(b) === BigInt.asIntN(64, b), + mod.rtBigIntI64(b) === oracleBigIntI64(b), ), { numRuns: FUZZ_RUNS, @@ -140,7 +145,7 @@ describe("rtBigIntU64", () => { it("matches BigInt.asUintN(64, ·)", () => { fc.assert( fc.property(bigIntArbI64, (b) => - mod.rtBigIntU64(b) === BigInt.asUintN(64, b), + mod.rtBigIntU64(b) === oracleBigIntU64(b), ), { numRuns: FUZZ_RUNS, @@ -155,8 +160,8 @@ describe("losslessI64", () => { fc.assert( fc.property(bigIntArbI64, (b) => { const { value, lossless } = mod.losslessI64(b); - const inRange = b >= -(1n << 63n) && b < 1n << 63n; - return value === BigInt.asIntN(64, b) && lossless === inRange; + const expected = oracleLosslessI64(b); + return value === expected.value && lossless === expected.lossless; }), { numRuns: FUZZ_RUNS, @@ -171,8 +176,8 @@ describe("losslessU64", () => { fc.assert( fc.property(bigIntArbI64, (b) => { const { value, lossless } = mod.losslessU64(b); - const inRange = b >= 0n && b < 1n << 64n; - return value === BigInt.asUintN(64, b) && lossless === inRange; + const expected = oracleLosslessU64(b); + return value === expected.value && lossless === expected.lossless; }), { numRuns: FUZZ_RUNS, @@ -187,17 +192,12 @@ const bigIntArbI128 = fc.oneof( { arbitrary: fc.constantFrom(...edgeBigInts), weight: 1 }, ); -const I128_MIN = -(1n << 127n); -const I128_MAX = (1n << 127n) - 1n; - describe("rtBigIntI128", () => { it("identity in [-2^127, 2^127); BigInt.asIntN(128, ·) elsewhere", () => { fc.assert( fc.property(bigIntArbI128, (b) => { const result = mod.rtBigIntI128(b); - const inRange = b >= I128_MIN && b <= I128_MAX; - const expected = inRange ? b : BigInt.asIntN(128, b); - return result === expected; + return result === oracleBigIntI128(b); }), { numRuns: FUZZ_RUNS, diff --git a/tests/fuzz_numeric/oracle.test.ts b/tests/fuzz_numeric/oracle.test.ts index 0506774..b2ecd8c 100644 --- a/tests/fuzz_numeric/oracle.test.ts +++ b/tests/fuzz_numeric/oracle.test.ts @@ -1,6 +1,19 @@ import { describe, it, expect } from "vitest"; import { createRequire } from "node:module"; import { edgeNumbers, edgeBigInts } from "./edges.ts"; +import { + I128_MAX, + I128_MIN, + oracleBigIntI128, + oracleBigIntI64, + oracleBigIntU64, + oracleLosslessI64, + oracleLosslessU64, + oracleNumberF64, + oracleNumberI32, + oracleNumberI64, + oracleNumberU32, +} from "./oracles.ts"; const require = createRequire(import.meta.url); const mod = require("../../zig-out/lib/test_fuzz_numeric.node") as { @@ -12,14 +25,6 @@ const mod = require("../../zig-out/lib/test_fuzz_numeric.node") as { rtBigIntU64(b: bigint): bigint; }; -/** - * Oracle for rtNumberF64: identity over all finite JS numbers; NaN ↔ NaN; - * ±0 preserved (distinguished via Object.is). - */ -function oracleF64(value: number): number { - return value; -} - function describeValue(v: number): string { if (Number.isNaN(v)) return "NaN"; if (Object.is(v, -0)) return "-0"; @@ -29,7 +34,7 @@ function describeValue(v: number): string { describe("oracle sanity: rtNumberF64", () => { for (const v of edgeNumbers) { it(`agrees with oracle on ${describeValue(v)}`, () => { - const expected = oracleF64(v); + const expected = oracleNumberF64(v); const actual = mod.rtNumberF64(v); if (Number.isNaN(expected)) { expect(Number.isNaN(actual)).toBe(true); @@ -40,40 +45,10 @@ describe("oracle sanity: rtNumberF64", () => { } }); -/** ECMAScript ToInt32: `value | 0`. NaN/±Inf → 0. */ -function oracleI32(value: number): number { - return value | 0; -} - -/** ECMAScript ToUint32: `value >>> 0`. NaN/±Inf → 0. */ -function oracleU32(value: number): number { - return value >>> 0; -} - -/** - * NAPI napi_get_value_int64 semantics (per Node.js NAPI docs): - * - NaN, ±Infinity → 0 - * - value >= 2^63 → INT64_MAX (clamped, not zero) - * - value < -2^63 → INT64_MIN (clamped) - * - exact -2^63 → INT64_MIN (representable, returned faithfully) - * - otherwise: BigInt(Math.trunc(value)) interpreted as i64 - * - * Returned as a JS bigint so the JS comparison stays lossless. - */ -const I64_MAX = (1n << 63n) - 1n; -const I64_MIN = -(1n << 63n); - -function oracleI64(value: number): bigint { - if (Number.isNaN(value) || !Number.isFinite(value)) return 0n; - if (value >= 2 ** 63) return I64_MAX; - if (value < -(2 ** 63)) return I64_MIN; - return BigInt(Math.trunc(value)); -} - describe("oracle sanity: rtNumberI32", () => { for (const v of edgeNumbers) { it(`agrees with oracle on ${describeValue(v)}`, () => { - expect(mod.rtNumberI32(v)).toBe(oracleI32(v)); + expect(mod.rtNumberI32(v)).toBe(oracleNumberI32(v)); }); } }); @@ -81,7 +56,7 @@ describe("oracle sanity: rtNumberI32", () => { describe("oracle sanity: rtNumberU32", () => { for (const v of edgeNumbers) { it(`agrees with oracle on ${describeValue(v)}`, () => { - expect(mod.rtNumberU32(v)).toBe(oracleU32(v)); + expect(mod.rtNumberU32(v)).toBe(oracleNumberU32(v)); }); } }); @@ -89,21 +64,11 @@ describe("oracle sanity: rtNumberU32", () => { describe("oracle sanity: rtNumberI64", () => { for (const v of edgeNumbers) { it(`agrees with oracle on ${describeValue(v)}`, () => { - expect(mod.rtNumberI64(v)).toBe(oracleI64(v)); + expect(mod.rtNumberI64(v)).toBe(oracleNumberI64(v)); }); } }); -/** Two's-complement low 64 bits, signed. */ -function oracleBigIntI64(b: bigint): bigint { - return BigInt.asIntN(64, b); -} - -/** Low 64 bits, unsigned. */ -function oracleBigIntU64(b: bigint): bigint { - return BigInt.asUintN(64, b); -} - describe("oracle sanity: rtBigIntI64", () => { for (const b of edgeBigInts) { it(`agrees with oracle on ${b}n`, () => { @@ -125,20 +90,10 @@ const modL = require("../../zig-out/lib/test_fuzz_numeric.node") as { losslessU64(b: bigint): { value: bigint; lossless: boolean }; }; -function expectedLosslessI64(b: bigint): { value: bigint; lossless: boolean } { - const losslessRange = b >= -(1n << 63n) && b < 1n << 63n; - return { value: BigInt.asIntN(64, b), lossless: losslessRange }; -} - -function expectedLosslessU64(b: bigint): { value: bigint; lossless: boolean } { - const losslessRange = b >= 0n && b < 1n << 64n; - return { value: BigInt.asUintN(64, b), lossless: losslessRange }; -} - describe("oracle sanity: losslessI64", () => { for (const b of edgeBigInts) { it(`agrees with oracle on ${b}n`, () => { - expect(modL.losslessI64(b)).toEqual(expectedLosslessI64(b)); + expect(modL.losslessI64(b)).toEqual(oracleLosslessI64(b)); }); } }); @@ -146,7 +101,7 @@ describe("oracle sanity: losslessI64", () => { describe("oracle sanity: losslessU64", () => { for (const b of edgeBigInts) { it(`agrees with oracle on ${b}n`, () => { - expect(modL.losslessU64(b)).toEqual(expectedLosslessU64(b)); + expect(modL.losslessU64(b)).toEqual(oracleLosslessU64(b)); }); } }); @@ -155,9 +110,6 @@ const modI128 = require("../../zig-out/lib/test_fuzz_numeric.node") as { rtBigIntI128(b: bigint): bigint; }; -const I128_MIN = -(1n << 127n); -const I128_MAX = (1n << 127n) - 1n; - describe("oracle sanity: rtBigIntI128", () => { for (const b of edgeBigInts) { // Only assert identity in-range. Out-of-range edges are out of scope @@ -187,12 +139,12 @@ describe("rtBigIntI128 boundary cases", () => { it("1n << 127n (just out-of-range positive) → BigInt.asIntN(128, 1n << 127n) === -(1n << 127n)", () => { const b = 1n << 127n; - expect(modI128.rtBigIntI128(b)).toBe(BigInt.asIntN(128, b)); + expect(modI128.rtBigIntI128(b)).toBe(oracleBigIntI128(b)); }); it("-(1n << 127n) - 1n (just out-of-range negative) → BigInt.asIntN(128, -(1n << 127n) - 1n)", () => { const b = -(1n << 127n) - 1n; - expect(modI128.rtBigIntI128(b)).toBe(BigInt.asIntN(128, b)); + expect(modI128.rtBigIntI128(b)).toBe(oracleBigIntI128(b)); }); it("1n << 128n (oversized positive, low 128 bits zero) → 0n", () => { diff --git a/tests/fuzz_numeric/oracles.ts b/tests/fuzz_numeric/oracles.ts new file mode 100644 index 0000000..ffe9b1b --- /dev/null +++ b/tests/fuzz_numeric/oracles.ts @@ -0,0 +1,60 @@ +/** + * Shared JS-side reference transforms for numeric fuzz targets. + * + * These are intentionally small and spec-shaped: the properties and oracle + * sanity tests should import the same functions so they cannot drift. + */ + +export const I64_MAX = (1n << 63n) - 1n; +export const I64_MIN = -(1n << 63n); +export const I128_MIN = -(1n << 127n); +export const I128_MAX = (1n << 127n) - 1n; + +export function oracleNumberF64(value: number): number { + return value; +} + +export function oracleNumberI32(value: number): number { + return value | 0; +} + +export function oracleNumberU32(value: number): number { + return value >>> 0; +} + +export function oracleNumberI64(value: number): bigint { + if (Number.isNaN(value) || !Number.isFinite(value)) return 0n; + if (value >= 2 ** 63) return I64_MAX; + if (value < -(2 ** 63)) return I64_MIN; + return BigInt(Math.trunc(value)); +} + +export function oracleBigIntI64(value: bigint): bigint { + return BigInt.asIntN(64, value); +} + +export function oracleBigIntU64(value: bigint): bigint { + return BigInt.asUintN(64, value); +} + +export function oracleLosslessI64(value: bigint): { + value: bigint; + lossless: boolean; +} { + const lossless = value >= I64_MIN && value <= I64_MAX; + return { value: oracleBigIntI64(value), lossless }; +} + +export function oracleLosslessU64(value: bigint): { + value: bigint; + lossless: boolean; +} { + const lossless = value >= 0n && value < 1n << 64n; + return { value: oracleBigIntU64(value), lossless }; +} + +export function oracleBigIntI128(value: bigint): bigint { + return value >= I128_MIN && value <= I128_MAX + ? value + : BigInt.asIntN(128, value); +} From 5fb58413112da71556f32783bbdd2ee6b5241391 Mon Sep 17 00:00:00 2001 From: Nazar Hussain Date: Fri, 29 May 2026 11:20:42 +0200 Subject: [PATCH 17/21] doc: update doc reference for practically accurate doc comments. --- README.md | 2 +- src/js/bigint.zig | 17 ++++++++++------- src/js/number.zig | 25 ++++++++++++++++++------- 3 files changed, 29 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 2a6993f..43d6e14 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ c.count; // 1 (getter, not a method call) | `Number` | `number` | `toI32()`, `toF64()`, `assertI32()`, `from(anytype)` | | `String` | `string` | `toSlice(buf)`, `toOwnedSlice(alloc)`, `len()`, `from([]const u8)` | | `Boolean` | `boolean` | `toBool()`, `assertBool()`, `from(bool)` | -| `BigInt` | `bigint` | `toI64()`, `toU64()`, `toI128()`, `from(anytype)` | +| `BigInt` | `bigint` | `toI64()`, `toU64()`, `toI128()`, `from(anytype)`, `fromWords(sign, words)` | | `Date` | `Date` | `toTimestamp()`, `from(f64)` | | `Array` | `Array` | `get(i)`, `getNumber(i)`, `length()`, `set(i, val)` | | `Object(T)` | `object` | `get()`, `set(value)` — `T` fields must be DSL types | diff --git a/src/js/bigint.zig b/src/js/bigint.zig index 0b2e3c3..e467c06 100644 --- a/src/js/bigint.zig +++ b/src/js/bigint.zig @@ -17,8 +17,10 @@ pub const BigInt = struct { /// Attempts to convert the JavaScript BigInt to a Zig `i64`. /// /// If `lossless` is provided, it will be set to `true` if the BigInt can be - /// represented exactly as an `i64`, and `false` otherwise (e.g., if the - /// BigInt is too large or too small for `i64`). + /// represented exactly as an `i64`, and `false` otherwise. Out-of-range + /// values are truncated to the low 64 bits, matching N-API BigInt + /// conversion semantics. + /// /// Returns an error if the conversion fails or the `napi_env` is invalid. pub fn toI64(self: BigInt, lossless: ?*bool) !i64 { return self.val.getValueBigintInt64(lossless); @@ -27,9 +29,11 @@ pub const BigInt = struct { /// Attempts to convert the JavaScript BigInt to a Zig `u64`. /// /// If `lossless` is provided, it will be set to `true` if the BigInt can be - /// represented exactly as a `u64`, and `false` otherwise. - /// Returns an error if the conversion fails (e.g., BigInt is negative) or - /// the `napi_env` is invalid. + /// represented exactly as a `u64`, and `false` otherwise. Out-of-range or + /// negative values are truncated to the low 64 bits, matching N-API BigInt + /// conversion semantics. + /// + /// Returns an error if the conversion fails or the `napi_env` is invalid. pub fn toU64(self: BigInt, lossless: ?*bool) !u64 { return self.val.getValueBigintUint64(lossless); } @@ -102,8 +106,7 @@ pub const BigInt = struct { /// Creates a JavaScript `BigInt` from a Zig integer value. /// /// Accepts `i64`, `u64`, and `comptime_int` values within this range. - /// For `i128`/`u128` values, use `fromWords` (not currently exposed but - /// available at the `napi.Env` level). + /// For `i128`/`u128` values, use `fromWords`. /// /// Panics if N-API operations fail (e.g., invalid environment) or for Zig /// integer types larger than `u64` that cannot be converted to `i64`/`u64`. diff --git a/src/js/number.zig b/src/js/number.zig index 9dc04e0..37bea40 100644 --- a/src/js/number.zig +++ b/src/js/number.zig @@ -27,19 +27,24 @@ pub const Number = struct { /// Attempts to convert the JavaScript number to a Zig `i32`. /// - /// Returns an error if the conversion fails (e.g., number is too large). + /// Follows N-API int32 conversion semantics: finite values outside the + /// 32-bit range are truncated to the equivalent low 32 bits, and non-finite + /// values (`NaN`, `+Infinity`, `-Infinity`) become 0. + /// + /// Returns an error if the underlying value is not a number or the N-API + /// call fails. pub fn toI32(self: Number) !i32 { return self.val.getValueInt32(); } /// Attempts to convert the JavaScript number to a Zig `u32`. /// - /// Safety: The underlying napi implementation does not reject numbers out of - /// the range [0, 2^32). The caller is responsible for bounds checking. - /// - /// Returns an error if the given `self.val` is not a number. + /// Follows N-API uint32 conversion semantics: finite values outside the + /// 32-bit range are truncated to the equivalent low 32 bits, and non-finite + /// values (`NaN`, `+Infinity`, `-Infinity`) become 0. /// - /// Source: https://tc39.es/ecma262/#sec-touint32 + /// Returns an error if the underlying value is not a number or the N-API + /// call fails. pub fn toU32(self: Number) !u32 { return self.val.getValueUint32(); } @@ -53,7 +58,13 @@ pub const Number = struct { /// Attempts to convert the JavaScript number to a Zig `i64`. /// - /// Returns an error if the conversion fails (e.g., number is too large). + /// Follows N-API int64 conversion semantics: non-finite values (`NaN`, + /// `+Infinity`, `-Infinity`) become 0. Values outside JavaScript's safe + /// integer range may lose precision, and finite values outside the `i64` + /// range are clamped by Node to `minInt(i64)`/`maxInt(i64)`. + /// + /// Returns an error if the underlying value is not a number or the N-API + /// call fails. pub fn toI64(self: Number) !i64 { return self.val.getValueInt64(); } From e472e7f7ed1cc40ee31f4242d1da95ddc0f78929 Mon Sep 17 00:00:00 2001 From: Nazar Hussain Date: Fri, 29 May 2026 11:50:47 +0200 Subject: [PATCH 18/21] fix: add to*LowBits interface for lossy values to keep the safety as default --- README.md | 2 +- examples/js_dsl/mod.zig | 3 +- src/js/bigint.zig | 95 +++++++++++++++++++++++-------- tests/fuzz_numeric/fuzz.test.ts | 64 ++++++++++++++++----- tests/fuzz_numeric/mod.zig | 45 ++++++++------- tests/fuzz_numeric/oracle.test.ts | 61 +++++++++++++------- tests/fuzz_numeric/oracles.ts | 24 ++++++-- 7 files changed, 206 insertions(+), 88 deletions(-) diff --git a/README.md b/README.md index 43d6e14..5235008 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ c.count; // 1 (getter, not a method call) | `Number` | `number` | `toI32()`, `toF64()`, `assertI32()`, `from(anytype)` | | `String` | `string` | `toSlice(buf)`, `toOwnedSlice(alloc)`, `len()`, `from([]const u8)` | | `Boolean` | `boolean` | `toBool()`, `assertBool()`, `from(bool)` | -| `BigInt` | `bigint` | `toI64()`, `toU64()`, `toI128()`, `from(anytype)`, `fromWords(sign, words)` | +| `BigInt` | `bigint` | `toI64()`, `toU64()`, `toI128()`, `to*LowBits()`, `from(anytype)`, `fromWords(sign, words)` | | `Date` | `Date` | `toTimestamp()`, `from(f64)` | | `Array` | `Array` | `get(i)`, `getNumber(i)`, `length()`, `set(i, val)` | | `Object(T)` | `object` | `get()`, `set(value)` — `T` fields must be DSL types | diff --git a/examples/js_dsl/mod.zig b/examples/js_dsl/mod.zig index 59ee1c8..0d1c34b 100644 --- a/examples/js_dsl/mod.zig +++ b/examples/js_dsl/mod.zig @@ -95,8 +95,7 @@ pub fn reverseString(s: String) !String { /// Double a BigInt value. pub fn doubleBigInt(n: BigInt) !BigInt { - var lossless: bool = false; - const val = try n.toI64(&lossless); + const val = try n.toI64(); return BigInt.from(val * 2); } diff --git a/src/js/bigint.zig b/src/js/bigint.zig index e467c06..5092ce8 100644 --- a/src/js/bigint.zig +++ b/src/js/bigint.zig @@ -14,41 +14,86 @@ pub const BigInt = struct { if ((try val.typeof()) != .bigint) return error.TypeMismatch; } - /// Attempts to convert the JavaScript BigInt to a Zig `i64`. - /// - /// If `lossless` is provided, it will be set to `true` if the BigInt can be - /// represented exactly as an `i64`, and `false` otherwise. Out-of-range - /// values are truncated to the low 64 bits, matching N-API BigInt - /// conversion semantics. + /// Attempts to convert the JavaScript BigInt exactly to a Zig `i64`. + /// + /// Returns `error.InvalidArg` if the value is outside the `i64` range. Use + /// `toI64LowBits` when low-64-bit conversion is intended. + pub fn toI64(self: BigInt) !i64 { + var lossless = false; + const value = try self.toI64LowBits(&lossless); + if (!lossless) return error.InvalidArg; + return value; + } + + /// Converts the JavaScript BigInt to the low 64 bits as a Zig `i64`. /// - /// Returns an error if the conversion fails or the `napi_env` is invalid. - pub fn toI64(self: BigInt, lossless: ?*bool) !i64 { + /// If `lossless` is provided, it is set to whether the returned `i64` + /// represents the original BigInt exactly. Out-of-range values still return + /// their low 64 bits, matching N-API BigInt conversion semantics, but set + /// `lossless` to false. + pub fn toI64LowBits(self: BigInt, lossless: ?*bool) !i64 { return self.val.getValueBigintInt64(lossless); } - /// Attempts to convert the JavaScript BigInt to a Zig `u64`. - /// - /// If `lossless` is provided, it will be set to `true` if the BigInt can be - /// represented exactly as a `u64`, and `false` otherwise. Out-of-range or - /// negative values are truncated to the low 64 bits, matching N-API BigInt - /// conversion semantics. + /// Attempts to convert the JavaScript BigInt exactly to a Zig `u64`. + /// + /// Returns `error.InvalidArg` if the value is negative or outside the `u64` + /// range. Use `toU64LowBits` when low-64-bit conversion is intended. + pub fn toU64(self: BigInt) !u64 { + var lossless = false; + const value = try self.toU64LowBits(&lossless); + if (!lossless) return error.InvalidArg; + return value; + } + + /// Converts the JavaScript BigInt to the low 64 bits as a Zig `u64`. /// - /// Returns an error if the conversion fails or the `napi_env` is invalid. - pub fn toU64(self: BigInt, lossless: ?*bool) !u64 { + /// If `lossless` is provided, it is set to whether the returned `u64` + /// represents the original BigInt exactly. Out-of-range or negative values + /// still return their low 64 bits, matching N-API BigInt conversion + /// semantics, but set `lossless` to false. + pub fn toU64LowBits(self: BigInt, lossless: ?*bool) !u64 { return self.val.getValueBigintUint64(lossless); } - /// Attempts to convert the JavaScript BigInt to a Zig `i128`. + /// Attempts to convert the JavaScript BigInt exactly to a Zig `i128`. /// - /// In-domain (`b ∈ [-2^127, 2^127)`): returns `b` exactly. + /// Returns `error.InvalidArg` if the value is outside `[-2^127, 2^127)`. + /// Use `toI128LowBits` when low-128-bit conversion is intended. /// - /// Out-of-domain: returns `BigInt.asIntN(128, b)`, i.e. the low 128 bits + /// Returns an error if N-API operations fail. + pub fn toI128(self: BigInt) !i128 { + var sign_bit: u1 = 0; + // Three words are enough to distinguish every in-range i128 from any + // oversized value, even when the lower 128 bits are zero. + var words: [3]u64 = .{ 0, 0, 0 }; + const result = try self.val.getValueBigintWords(&sign_bit, &words); + if (result.len > 2) return error.InvalidArg; + + const lo: u128 = words[0]; + const hi: u128 = words[1]; + const magnitude: u128 = (hi << 64) | lo; + if (sign_bit == 1) { + if (magnitude == 0) return 0; + if (magnitude > (@as(u128, 1) << 127)) return error.InvalidArg; + return @bitCast(0 -% magnitude); + } + + if (magnitude > @as(u128, @intCast(std.math.maxInt(i128)))) { + return error.InvalidArg; + } + return @intCast(magnitude); + } + + /// Converts the JavaScript BigInt to the low 128 bits as a Zig `i128`. + /// + /// This returns `BigInt.asIntN(128, b)`, i.e. the low 128 bits /// of the magnitude reinterpreted as a signed i128. This matches the /// ECMAScript `BigInt.asIntN` semantics defined at /// https://tc39.es/ecma262/#sec-bigint.asintn. /// /// Returns an error if N-API operations fail. - pub fn toI128(self: BigInt) !i128 { + pub fn toI128LowBits(self: BigInt) !i128 { var sign_bit: u1 = 0; // Pre-zeroed: NAPI writes only as many words as the BigInt has; unused // words stay 0. When the value is 0n NAPI returns word_count == 0, so @@ -82,23 +127,23 @@ pub const BigInt = struct { /// Converts the JavaScript BigInt to a Zig `i64`, panicking on failure. /// /// This is a convenience method for cases where the BigInt is guaranteed - /// to be representable as an `i64` without loss. + /// to be exactly representable as an `i64`. pub fn assertI64(self: BigInt) i64 { - return self.toI64(null) catch @panic("BigInt.assertI64 failed"); + return self.toI64() catch @panic("BigInt.assertI64 failed"); } /// Converts the JavaScript BigInt to a Zig `u64`, panicking on failure. /// /// This is a convenience method for cases where the BigInt is guaranteed - /// to be representable as a `u64` without loss. + /// to be exactly representable as a `u64`. pub fn assertU64(self: BigInt) u64 { - return self.toU64(null) catch @panic("BigInt.assertU64 failed"); + return self.toU64() catch @panic("BigInt.assertU64 failed"); } /// Converts the JavaScript BigInt to a Zig `i128`, panicking on failure. /// /// This is a convenience method for cases where the BigInt is guaranteed - /// to be representable as an `i128`. + /// to be exactly representable as an `i128`. pub fn assertI128(self: BigInt) i128 { return self.toI128() catch @panic("BigInt.assertI128 failed"); } diff --git a/tests/fuzz_numeric/fuzz.test.ts b/tests/fuzz_numeric/fuzz.test.ts index fa99647..0dc06b6 100644 --- a/tests/fuzz_numeric/fuzz.test.ts +++ b/tests/fuzz_numeric/fuzz.test.ts @@ -6,9 +6,11 @@ import * as url from "node:url"; import fc from "fast-check"; import { edgeNumbers, edgeBigInts } from "./edges.ts"; import { + fitsBigIntI128, + fitsBigIntI64, + fitsBigIntU64, oracleBigIntI128, - oracleBigIntI64, - oracleBigIntU64, + oracleBigIntI128LowBits, oracleLosslessI64, oracleLosslessU64, oracleNumberF64, @@ -28,6 +30,7 @@ const mod = require("../../zig-out/lib/test_fuzz_numeric.node") as { losslessI64(b: bigint): { value: bigint; lossless: boolean }; losslessU64(b: bigint): { value: bigint; lossless: boolean }; rtBigIntI128(b: bigint): bigint; + rtBigIntI128LowBits(b: bigint): bigint; rtBigIntWords(b: bigint): bigint; }; @@ -128,11 +131,18 @@ const bigIntArbI64 = fc.oneof( ); describe("rtBigIntI64", () => { - it("matches BigInt.asIntN(64, ·)", () => { + it("is exact in [-2^63, 2^63) and throws outside that range", () => { fc.assert( - fc.property(bigIntArbI64, (b) => - mod.rtBigIntI64(b) === oracleBigIntI64(b), - ), + fc.property(bigIntArbI64, (b) => { + if (fitsBigIntI64(b)) return mod.rtBigIntI64(b) === b; + + try { + mod.rtBigIntI64(b); + return false; + } catch { + return true; + } + }), { numRuns: FUZZ_RUNS, examples: loadSeeds("rtBigIntI64", reviveBigInt), @@ -142,11 +152,18 @@ describe("rtBigIntI64", () => { }); describe("rtBigIntU64", () => { - it("matches BigInt.asUintN(64, ·)", () => { + it("is exact in [0, 2^64) and throws outside that range", () => { fc.assert( - fc.property(bigIntArbI64, (b) => - mod.rtBigIntU64(b) === oracleBigIntU64(b), - ), + fc.property(bigIntArbI64, (b) => { + if (fitsBigIntU64(b)) return mod.rtBigIntU64(b) === b; + + try { + mod.rtBigIntU64(b); + return false; + } catch { + return true; + } + }), { numRuns: FUZZ_RUNS, examples: loadSeeds("rtBigIntU64", reviveBigInt), @@ -193,11 +210,17 @@ const bigIntArbI128 = fc.oneof( ); describe("rtBigIntI128", () => { - it("identity in [-2^127, 2^127); BigInt.asIntN(128, ·) elsewhere", () => { + it("is exact in [-2^127, 2^127) and throws outside that range", () => { fc.assert( fc.property(bigIntArbI128, (b) => { - const result = mod.rtBigIntI128(b); - return result === oracleBigIntI128(b); + if (fitsBigIntI128(b)) return mod.rtBigIntI128(b) === oracleBigIntI128(b); + + try { + mod.rtBigIntI128(b); + return false; + } catch { + return true; + } }), { numRuns: FUZZ_RUNS, @@ -207,6 +230,21 @@ describe("rtBigIntI128", () => { }); }); +describe("rtBigIntI128LowBits", () => { + it("matches BigInt.asIntN(128, ·)", () => { + fc.assert( + fc.property( + bigIntArbI128, + (b) => mod.rtBigIntI128LowBits(b) === oracleBigIntI128LowBits(b), + ), + { + numRuns: FUZZ_RUNS, + examples: loadSeeds("rtBigIntI128LowBits", reviveBigInt), + }, + ); + }); +}); + const bigIntArbWords = fc.oneof( // Keep within the addon's 64-word cap (4096 bits). { diff --git a/tests/fuzz_numeric/mod.zig b/tests/fuzz_numeric/mod.zig index 2a39e89..73f60d2 100644 --- a/tests/fuzz_numeric/mod.zig +++ b/tests/fuzz_numeric/mod.zig @@ -25,25 +25,21 @@ pub fn rtNumberI64(n: js.Number) !js.BigInt { return js.BigInt.from(try n.toI64()); } -/// Round-trip via NAPI int64. The `lossless` flag is discarded; the result -/// equals BigInt.asIntN(64, b) regardless. See `losslessI64` (Task 6) for -/// flag-exposing variants. +/// Exact round-trip via i64. Throws for out-of-range inputs. pub fn rtBigIntI64(b: js.BigInt) !js.BigInt { - var lossless: bool = false; - return js.BigInt.from(try b.toI64(&lossless)); + return js.BigInt.from(try b.toI64()); } -/// Round-trip via NAPI uint64. Result equals BigInt.asUintN(64, b). +/// Exact round-trip via u64. Throws for negative or out-of-range inputs. pub fn rtBigIntU64(b: js.BigInt) !js.BigInt { - var lossless: bool = false; - return js.BigInt.from(try b.toU64(&lossless)); + return js.BigInt.from(try b.toU64()); } -/// Returns `{ value: BigInt, lossless: Boolean }` exposing toI64's lossless -/// out-parameter to the fuzzer. +/// Returns `{ value: BigInt, lossless: Boolean }` exposing the lossy low-bits +/// conversion flag to the fuzzer. pub fn losslessI64(b: js.BigInt) !js.Value { var lossless: bool = false; - const v = try b.toI64(&lossless); + const v = try b.toI64LowBits(&lossless); const value = js.BigInt.from(v); const flag = js.Boolean.from(lossless); const obj = try js.env().createObject(); @@ -52,10 +48,10 @@ pub fn losslessI64(b: js.BigInt) !js.Value { return .{ .val = obj }; } -/// Returns `{ value: BigInt, lossless: Boolean }` for toU64. +/// Returns `{ value: BigInt, lossless: Boolean }` for the u64 low-bits path. pub fn losslessU64(b: js.BigInt) !js.Value { var lossless: bool = false; - const v = try b.toU64(&lossless); + const v = try b.toU64LowBits(&lossless); const value = js.BigInt.from(v); const flag = js.Boolean.from(lossless); const obj = try js.env().createObject(); @@ -64,13 +60,7 @@ pub fn losslessU64(b: js.BigInt) !js.Value { return .{ .val = obj }; } -/// Round-trip JS BigInt → i128 → JS BigInt via fromWords. -/// -/// In-domain (b ∈ [-2^127, 2^127)) this is identity. Out-of-domain behavior -/// is determined by `BigInt.toI128` (currently: truncates to low 128 bits). -/// The fuzzer surfaces any mismatch against the oracle in fuzz.test.ts. -pub fn rtBigIntI128(b: js.BigInt) !js.BigInt { - const v = try b.toI128(); +fn i128ToBigInt(v: i128) !js.BigInt { const is_negative = v < 0; const magnitude: u128 = if (is_negative) @as(u128, @intCast(-(v + 1))) + 1 // safe for i128.minInt @@ -83,6 +73,21 @@ pub fn rtBigIntI128(b: js.BigInt) !js.BigInt { return js.BigInt.fromWords(if (is_negative) 1 else 0, &words); } +/// Round-trip JS BigInt → i128 → JS BigInt via fromWords. +/// +/// In-domain (b ∈ [-2^127, 2^127)) this is identity. Out-of-domain values +/// throw instead of silently dropping high bits. +pub fn rtBigIntI128(b: js.BigInt) !js.BigInt { + return i128ToBigInt(try b.toI128()); +} + +/// Round-trip JS BigInt → low 128 bits → JS BigInt. +/// +/// Oracle: `BigInt.asIntN(128, b)`. +pub fn rtBigIntI128LowBits(b: js.BigInt) !js.BigInt { + return i128ToBigInt(try b.toI128LowBits()); +} + /// Round-trip via getValueBigintWords → createBigintWords (via fromWords). /// Identity for any BigInt that fits in the addon's word buffer (64 words = /// 4096 bits is the practical cap). diff --git a/tests/fuzz_numeric/oracle.test.ts b/tests/fuzz_numeric/oracle.test.ts index b2ecd8c..ea36109 100644 --- a/tests/fuzz_numeric/oracle.test.ts +++ b/tests/fuzz_numeric/oracle.test.ts @@ -2,11 +2,11 @@ import { describe, it, expect } from "vitest"; import { createRequire } from "node:module"; import { edgeNumbers, edgeBigInts } from "./edges.ts"; import { - I128_MAX, - I128_MIN, + fitsBigIntI128, + fitsBigIntI64, + fitsBigIntU64, oracleBigIntI128, - oracleBigIntI64, - oracleBigIntU64, + oracleBigIntI128LowBits, oracleLosslessI64, oracleLosslessU64, oracleNumberF64, @@ -71,16 +71,24 @@ describe("oracle sanity: rtNumberI64", () => { describe("oracle sanity: rtBigIntI64", () => { for (const b of edgeBigInts) { - it(`agrees with oracle on ${b}n`, () => { - expect(mod.rtBigIntI64(b)).toBe(oracleBigIntI64(b)); + it(`is exact-or-throw on ${b}n`, () => { + if (fitsBigIntI64(b)) { + expect(mod.rtBigIntI64(b)).toBe(b); + } else { + expect(() => mod.rtBigIntI64(b)).toThrow(); + } }); } }); describe("oracle sanity: rtBigIntU64", () => { for (const b of edgeBigInts) { - it(`agrees with oracle on ${b}n`, () => { - expect(mod.rtBigIntU64(b)).toBe(oracleBigIntU64(b)); + it(`is exact-or-throw on ${b}n`, () => { + if (fitsBigIntU64(b)) { + expect(mod.rtBigIntU64(b)).toBe(b); + } else { + expect(() => mod.rtBigIntU64(b)).toThrow(); + } }); } }); @@ -108,16 +116,17 @@ describe("oracle sanity: losslessU64", () => { const modI128 = require("../../zig-out/lib/test_fuzz_numeric.node") as { rtBigIntI128(b: bigint): bigint; + rtBigIntI128LowBits(b: bigint): bigint; }; describe("oracle sanity: rtBigIntI128", () => { for (const b of edgeBigInts) { - // Only assert identity in-range. Out-of-range edges are out of scope - // for the oracle test — the property test handles them via the - // implementation-defined policy (see fuzz.test.ts). - if (b < I128_MIN || b > I128_MAX) continue; - it(`round-trips ${b}n`, () => { - expect(modI128.rtBigIntI128(b)).toBe(b); + it(`is exact-or-throw on ${b}n`, () => { + if (fitsBigIntI128(b)) { + expect(modI128.rtBigIntI128(b)).toBe(oracleBigIntI128(b)); + } else { + expect(() => modI128.rtBigIntI128(b)).toThrow(); + } }); } }); @@ -137,25 +146,33 @@ describe("rtBigIntI128 boundary cases", () => { expect(modI128.rtBigIntI128(b)).toBe(b); }); - it("1n << 127n (just out-of-range positive) → BigInt.asIntN(128, 1n << 127n) === -(1n << 127n)", () => { + it("1n << 127n (just out-of-range positive) throws", () => { const b = 1n << 127n; - expect(modI128.rtBigIntI128(b)).toBe(oracleBigIntI128(b)); + expect(() => modI128.rtBigIntI128(b)).toThrow(); }); - it("-(1n << 127n) - 1n (just out-of-range negative) → BigInt.asIntN(128, -(1n << 127n) - 1n)", () => { + it("-(1n << 127n) - 1n (just out-of-range negative) throws", () => { const b = -(1n << 127n) - 1n; - expect(modI128.rtBigIntI128(b)).toBe(oracleBigIntI128(b)); + expect(() => modI128.rtBigIntI128(b)).toThrow(); }); - it("1n << 128n (oversized positive, low 128 bits zero) → 0n", () => { - expect(modI128.rtBigIntI128(1n << 128n)).toBe(0n); + it("1n << 128n (oversized positive) throws", () => { + expect(() => modI128.rtBigIntI128(1n << 128n)).toThrow(); }); - it("-(1n << 128n) (oversized negative, low 128 bits zero) → 0n", () => { - expect(modI128.rtBigIntI128(-(1n << 128n))).toBe(0n); + it("-(1n << 128n) (oversized negative) throws", () => { + expect(() => modI128.rtBigIntI128(-(1n << 128n))).toThrow(); }); }); +describe("oracle sanity: rtBigIntI128LowBits", () => { + for (const b of edgeBigInts) { + it(`agrees with low-bits oracle on ${b}n`, () => { + expect(modI128.rtBigIntI128LowBits(b)).toBe(oracleBigIntI128LowBits(b)); + }); + } +}); + const modW = require("../../zig-out/lib/test_fuzz_numeric.node") as { rtBigIntWords(b: bigint): bigint; }; diff --git a/tests/fuzz_numeric/oracles.ts b/tests/fuzz_numeric/oracles.ts index ffe9b1b..f8906cd 100644 --- a/tests/fuzz_numeric/oracles.ts +++ b/tests/fuzz_numeric/oracles.ts @@ -33,15 +33,23 @@ export function oracleBigIntI64(value: bigint): bigint { return BigInt.asIntN(64, value); } +export function fitsBigIntI64(value: bigint): boolean { + return value >= I64_MIN && value <= I64_MAX; +} + export function oracleBigIntU64(value: bigint): bigint { return BigInt.asUintN(64, value); } +export function fitsBigIntU64(value: bigint): boolean { + return value >= 0n && value < 1n << 64n; +} + export function oracleLosslessI64(value: bigint): { value: bigint; lossless: boolean; } { - const lossless = value >= I64_MIN && value <= I64_MAX; + const lossless = fitsBigIntI64(value); return { value: oracleBigIntI64(value), lossless }; } @@ -49,12 +57,18 @@ export function oracleLosslessU64(value: bigint): { value: bigint; lossless: boolean; } { - const lossless = value >= 0n && value < 1n << 64n; + const lossless = fitsBigIntU64(value); return { value: oracleBigIntU64(value), lossless }; } +export function fitsBigIntI128(value: bigint): boolean { + return value >= I128_MIN && value <= I128_MAX; +} + export function oracleBigIntI128(value: bigint): bigint { - return value >= I128_MIN && value <= I128_MAX - ? value - : BigInt.asIntN(128, value); + return value; +} + +export function oracleBigIntI128LowBits(value: bigint): bigint { + return BigInt.asIntN(128, value); } From 558f7c3f557e87918c8ff1b03e1e48c1601358b4 Mon Sep 17 00:00:00 2001 From: Nazar Hussain Date: Fri, 29 May 2026 12:07:10 +0200 Subject: [PATCH 19/21] test: add uint8array fuzz tests --- tests/fuzz_numeric/README.md | 4 ++-- tests/fuzz_numeric/edges.ts | 8 ++++++++ tests/fuzz_numeric/fuzz.test.ts | 31 ++++++++++++++++++++++++++++++- tests/fuzz_numeric/mod.zig | 5 +++++ tests/fuzz_numeric/oracle.test.ts | 15 ++++++++++++++- tests/fuzz_numeric/oracles.ts | 12 ++++++++++++ 6 files changed, 71 insertions(+), 4 deletions(-) diff --git a/tests/fuzz_numeric/README.md b/tests/fuzz_numeric/README.md index 2c54884..6840788 100644 --- a/tests/fuzz_numeric/README.md +++ b/tests/fuzz_numeric/README.md @@ -1,6 +1,6 @@ # fuzz_numeric -Property-based fuzz tests for zapi's Number and BigInt conversions. Driven from JS through the `test_fuzz_numeric.node` addon (built by `zig build`) using vitest + fast-check. +Property-based fuzz tests for zapi's Number, BigInt, and selected binary conversion surfaces. Driven from JS through the `test_fuzz_numeric.node` addon (built by `zig build`) using vitest + fast-check. See `docs/superpowers/specs/2026-05-28-fuzz-testing-design.md` for design rationale. @@ -46,7 +46,7 @@ Once fixed, **persist the counterexample** so it runs forever: ```json { "__bigint": "170141183460469231731687303715884105728" } ``` - For numbers, use a JSON number directly. NaN and ±Infinity aren't representable in JSON and are not currently supported as persisted seeds — those values are already in `edgeNumbers` and exercised on every run, so persisting them as regression cases adds nothing. If a future fuzz target needs persisted special-number seeds, extend `loadSeeds` and the relevant `revive*` helper at that time. + For numbers, use a JSON number directly. For `Uint8Array`, use a JSON array of byte values, e.g. `[0, 255, 1]`. NaN and ±Infinity aren't representable in JSON and are not currently supported as persisted seeds — those values are already in `edgeNumbers` and exercised on every run, so persisting them as regression cases adds nothing. If a future fuzz target needs persisted special-number seeds, extend `loadSeeds` and the relevant `revive*` helper at that time. 3. Commit with the fix. ## Adding a new fuzz target diff --git a/tests/fuzz_numeric/edges.ts b/tests/fuzz_numeric/edges.ts index e393991..105cb70 100644 --- a/tests/fuzz_numeric/edges.ts +++ b/tests/fuzz_numeric/edges.ts @@ -50,3 +50,11 @@ export const edgeBigInts: readonly bigint[] = [ (1n << 192n) - 1n, 1n << 256n, ]; + +export const edgeUint8Arrays: readonly Uint8Array[] = [ + new Uint8Array([]), + new Uint8Array([0]), + new Uint8Array([255]), + new Uint8Array([0, 255, 1, 254]), + new Uint8Array(Array.from({ length: 256 }, (_, i) => i)), +]; diff --git a/tests/fuzz_numeric/fuzz.test.ts b/tests/fuzz_numeric/fuzz.test.ts index 0dc06b6..a30589f 100644 --- a/tests/fuzz_numeric/fuzz.test.ts +++ b/tests/fuzz_numeric/fuzz.test.ts @@ -4,8 +4,9 @@ import * as fs from "node:fs"; import * as path from "node:path"; import * as url from "node:url"; import fc from "fast-check"; -import { edgeNumbers, edgeBigInts } from "./edges.ts"; +import { edgeNumbers, edgeBigInts, edgeUint8Arrays } from "./edges.ts"; import { + equalUint8Array, fitsBigIntI128, fitsBigIntI64, fitsBigIntU64, @@ -17,6 +18,7 @@ import { oracleNumberI32, oracleNumberI64, oracleNumberU32, + oracleUint8Array, } from "./oracles.ts"; const require = createRequire(import.meta.url); @@ -32,6 +34,7 @@ const mod = require("../../zig-out/lib/test_fuzz_numeric.node") as { rtBigIntI128(b: bigint): bigint; rtBigIntI128LowBits(b: bigint): bigint; rtBigIntWords(b: bigint): bigint; + rtUint8Array(value: Uint8Array): Uint8Array; }; const FUZZ_RUNS = Number(process.env.FUZZ_RUNS ?? 10_000); @@ -58,6 +61,13 @@ function reviveBigInt(raw: unknown): bigint { throw new Error(`Cannot revive as bigint: ${JSON.stringify(raw)}`); } +function reviveUint8Array(raw: unknown): Uint8Array { + if (Array.isArray(raw) && raw.every((v) => Number.isInteger(v))) { + return new Uint8Array(raw); + } + throw new Error(`Cannot revive as Uint8Array: ${JSON.stringify(raw)}`); +} + const numberArb = fc.oneof( { arbitrary: fc.double(), weight: 4 }, { arbitrary: fc.constantFrom(...edgeNumbers), weight: 1 }, @@ -265,3 +275,22 @@ describe("rtBigIntWords", () => { ); }); }); + +const uint8ArrayArb = fc.oneof( + { arbitrary: fc.uint8Array({ maxLength: 4096 }), weight: 4 }, + { arbitrary: fc.constantFrom(...edgeUint8Arrays), weight: 1 }, +); + +describe("rtUint8Array", () => { + it("round-trips bytes unchanged", () => { + fc.assert( + fc.property(uint8ArrayArb, (value) => + equalUint8Array(mod.rtUint8Array(value), oracleUint8Array(value)), + ), + { + numRuns: FUZZ_RUNS, + examples: loadSeeds("rtUint8Array", reviveUint8Array), + }, + ); + }); +}); diff --git a/tests/fuzz_numeric/mod.zig b/tests/fuzz_numeric/mod.zig index 73f60d2..aebe211 100644 --- a/tests/fuzz_numeric/mod.zig +++ b/tests/fuzz_numeric/mod.zig @@ -105,6 +105,11 @@ pub fn rtBigIntWords(b: js.BigInt) !js.BigInt { return js.BigInt.fromWords(sign_bit, slice); } +/// Round-trip JS Uint8Array → []u8 → JS Uint8Array. +pub fn rtUint8Array(value: js.Uint8Array) !js.Uint8Array { + return js.Uint8Array.from(try value.toSlice()); +} + comptime { js.exportModule(@This(), .{}); } diff --git a/tests/fuzz_numeric/oracle.test.ts b/tests/fuzz_numeric/oracle.test.ts index ea36109..89fd6a4 100644 --- a/tests/fuzz_numeric/oracle.test.ts +++ b/tests/fuzz_numeric/oracle.test.ts @@ -1,7 +1,8 @@ import { describe, it, expect } from "vitest"; import { createRequire } from "node:module"; -import { edgeNumbers, edgeBigInts } from "./edges.ts"; +import { edgeNumbers, edgeBigInts, edgeUint8Arrays } from "./edges.ts"; import { + equalUint8Array, fitsBigIntI128, fitsBigIntI64, fitsBigIntU64, @@ -13,6 +14,7 @@ import { oracleNumberI32, oracleNumberI64, oracleNumberU32, + oracleUint8Array, } from "./oracles.ts"; const require = createRequire(import.meta.url); @@ -175,6 +177,7 @@ describe("oracle sanity: rtBigIntI128LowBits", () => { const modW = require("../../zig-out/lib/test_fuzz_numeric.node") as { rtBigIntWords(b: bigint): bigint; + rtUint8Array(value: Uint8Array): Uint8Array; }; describe("oracle sanity: rtBigIntWords", () => { @@ -184,3 +187,13 @@ describe("oracle sanity: rtBigIntWords", () => { }); } }); + +describe("oracle sanity: rtUint8Array", () => { + for (const value of edgeUint8Arrays) { + it(`round-trips ${value.length} byte(s)`, () => { + expect(equalUint8Array(modW.rtUint8Array(value), oracleUint8Array(value))).toBe( + true, + ); + }); + } +}); diff --git a/tests/fuzz_numeric/oracles.ts b/tests/fuzz_numeric/oracles.ts index f8906cd..7fa64c3 100644 --- a/tests/fuzz_numeric/oracles.ts +++ b/tests/fuzz_numeric/oracles.ts @@ -72,3 +72,15 @@ export function oracleBigIntI128(value: bigint): bigint { export function oracleBigIntI128LowBits(value: bigint): bigint { return BigInt.asIntN(128, value); } + +export function oracleUint8Array(value: Uint8Array): Uint8Array { + return new Uint8Array(value); +} + +export function equalUint8Array(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i += 1) { + if (a[i] !== b[i]) return false; + } + return true; +} From 82a3e1b6c2b822b4b1cf96016b420cc73caf265e Mon Sep 17 00:00:00 2001 From: Nazar Hussain Date: Fri, 29 May 2026 12:13:32 +0200 Subject: [PATCH 20/21] chore: restructure tests files --- build.zig.zon | 2 +- tests/{fuzz_numeric => fuzz}/README.md | 10 +- tests/fuzz/bigint.fuzz.test.ts | 154 +++++++++ tests/{fuzz_numeric => fuzz}/mod.zig | 0 tests/fuzz/number.fuzz.test.ts | 77 +++++ tests/{fuzz_numeric => fuzz}/oracle.test.ts | 59 +--- tests/{fuzz_numeric => fuzz}/seeds/.gitkeep | 0 tests/fuzz/uint8array.fuzz.test.ts | 29 ++ tests/fuzz_numeric/fuzz.test.ts | 296 ------------------ .../{fuzz_numeric/edges.ts => utils/edge.ts} | 0 tests/{fuzz_numeric => utils}/oracles.ts | 0 tests/utils/support.ts | 54 ++++ 12 files changed, 337 insertions(+), 344 deletions(-) rename tests/{fuzz_numeric => fuzz}/README.md (87%) create mode 100644 tests/fuzz/bigint.fuzz.test.ts rename tests/{fuzz_numeric => fuzz}/mod.zig (100%) create mode 100644 tests/fuzz/number.fuzz.test.ts rename tests/{fuzz_numeric => fuzz}/oracle.test.ts (66%) rename tests/{fuzz_numeric => fuzz}/seeds/.gitkeep (100%) create mode 100644 tests/fuzz/uint8array.fuzz.test.ts delete mode 100644 tests/fuzz_numeric/fuzz.test.ts rename tests/{fuzz_numeric/edges.ts => utils/edge.ts} (100%) rename tests/{fuzz_numeric => utils}/oracles.ts (100%) create mode 100644 tests/utils/support.ts diff --git a/build.zig.zon b/build.zig.zon index be97229..421c92a 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -50,7 +50,7 @@ .link_libc = true, }, .test_fuzz_numeric = .{ - .root_source_file = "tests/fuzz_numeric/mod.zig", + .root_source_file = "tests/fuzz/mod.zig", .imports = .{.zapi}, .link_libc = true, }, diff --git a/tests/fuzz_numeric/README.md b/tests/fuzz/README.md similarity index 87% rename from tests/fuzz_numeric/README.md rename to tests/fuzz/README.md index 6840788..a82ebd9 100644 --- a/tests/fuzz_numeric/README.md +++ b/tests/fuzz/README.md @@ -1,4 +1,4 @@ -# fuzz_numeric +# fuzz Property-based fuzz tests for zapi's Number, BigInt, and selected binary conversion surfaces. Driven from JS through the `test_fuzz_numeric.node` addon (built by `zig build`) using vitest + fast-check. @@ -14,7 +14,7 @@ pnpm test:fuzz FUZZ_RUNS=1000 pnpm test:fuzz # Single property -pnpm vitest run tests/fuzz_numeric/fuzz.test.ts -t "rtBigIntI128" +pnpm vitest run tests/fuzz/bigint.fuzz.test.ts -t "rtBigIntI128" ``` ## When a property fails @@ -41,7 +41,7 @@ it.only("repro", () => { Once fixed, **persist the counterexample** so it runs forever: -1. Create or open `tests/fuzz_numeric/seeds/.json`. +1. Create or open `tests/fuzz/seeds/.json`. 2. Append the counterexample to the JSON array. For bigints, use: ```json { "__bigint": "170141183460469231731687303715884105728" } @@ -53,6 +53,6 @@ Once fixed, **persist the counterexample** so it runs forever: 1. Add the round-trip export to `mod.zig`. 2. Add the oracle to `oracle.test.ts` and run it against every entry in the relevant edge list. -3. Add the fast-check property to `fuzz.test.ts`. -4. Update `edges.ts` if the target needs new edge values. +3. Add the fast-check property to the relevant `*.fuzz.test.ts` file. +4. Update `tests/utils/edge.ts` if the target needs new edge values. 5. Run `pnpm test:fuzz` and watch it pass (or find a bug). diff --git a/tests/fuzz/bigint.fuzz.test.ts b/tests/fuzz/bigint.fuzz.test.ts new file mode 100644 index 0000000..844d728 --- /dev/null +++ b/tests/fuzz/bigint.fuzz.test.ts @@ -0,0 +1,154 @@ +import { describe, it } from "vitest"; +import fc from "fast-check"; +import { edgeBigInts } from "../utils/edge.ts"; +import { + fitsBigIntI128, + fitsBigIntI64, + fitsBigIntU64, + oracleBigIntI128, + oracleBigIntI128LowBits, + oracleLosslessI64, + oracleLosslessU64, +} from "../utils/oracles.ts"; +import { FUZZ_RUNS, loadSeeds, mod, reviveBigInt } from "../utils/support.ts"; + +const bigIntArbI64 = fc.oneof( + { arbitrary: fc.bigInt({ min: -(2n ** 65n), max: 2n ** 65n }), weight: 4 }, + { arbitrary: fc.constantFrom(...edgeBigInts), weight: 1 }, +); + +describe("rtBigIntI64", () => { + it("is exact in [-2^63, 2^63) and throws outside that range", () => { + fc.assert( + fc.property(bigIntArbI64, (b) => { + if (fitsBigIntI64(b)) return mod.rtBigIntI64(b) === b; + + try { + mod.rtBigIntI64(b); + return false; + } catch { + return true; + } + }), + { + numRuns: FUZZ_RUNS, + examples: loadSeeds("rtBigIntI64", reviveBigInt), + }, + ); + }); +}); + +describe("rtBigIntU64", () => { + it("is exact in [0, 2^64) and throws outside that range", () => { + fc.assert( + fc.property(bigIntArbI64, (b) => { + if (fitsBigIntU64(b)) return mod.rtBigIntU64(b) === b; + + try { + mod.rtBigIntU64(b); + return false; + } catch { + return true; + } + }), + { + numRuns: FUZZ_RUNS, + examples: loadSeeds("rtBigIntU64", reviveBigInt), + }, + ); + }); +}); + +describe("losslessI64", () => { + it("lossless ⇔ b ∈ [-2^63, 2^63) and value matches asIntN", () => { + fc.assert( + fc.property(bigIntArbI64, (b) => { + const { value, lossless } = mod.losslessI64(b); + const expected = oracleLosslessI64(b); + return value === expected.value && lossless === expected.lossless; + }), + { + numRuns: FUZZ_RUNS, + examples: loadSeeds("losslessI64", reviveBigInt), + }, + ); + }); +}); + +describe("losslessU64", () => { + it("lossless ⇔ b ∈ [0, 2^64) and value matches asUintN", () => { + fc.assert( + fc.property(bigIntArbI64, (b) => { + const { value, lossless } = mod.losslessU64(b); + const expected = oracleLosslessU64(b); + return value === expected.value && lossless === expected.lossless; + }), + { + numRuns: FUZZ_RUNS, + examples: loadSeeds("losslessU64", reviveBigInt), + }, + ); + }); +}); + +const bigIntArbI128 = fc.oneof( + { arbitrary: fc.bigInt({ min: -(2n ** 129n), max: 2n ** 129n }), weight: 4 }, + { arbitrary: fc.constantFrom(...edgeBigInts), weight: 1 }, +); + +describe("rtBigIntI128", () => { + it("is exact in [-2^127, 2^127) and throws outside that range", () => { + fc.assert( + fc.property(bigIntArbI128, (b) => { + if (fitsBigIntI128(b)) return mod.rtBigIntI128(b) === oracleBigIntI128(b); + + try { + mod.rtBigIntI128(b); + return false; + } catch { + return true; + } + }), + { + numRuns: FUZZ_RUNS, + examples: loadSeeds("rtBigIntI128", reviveBigInt), + }, + ); + }); +}); + +describe("rtBigIntI128LowBits", () => { + it("matches BigInt.asIntN(128, ·)", () => { + fc.assert( + fc.property( + bigIntArbI128, + (b) => mod.rtBigIntI128LowBits(b) === oracleBigIntI128LowBits(b), + ), + { + numRuns: FUZZ_RUNS, + examples: loadSeeds("rtBigIntI128LowBits", reviveBigInt), + }, + ); + }); +}); + +const bigIntArbWords = fc.oneof( + // Keep within the addon's 64-word cap (4096 bits). + { + arbitrary: fc.bigInt({ min: -(2n ** 4000n), max: 2n ** 4000n }), + weight: 4, + }, + { arbitrary: fc.constantFrom(...edgeBigInts), weight: 1 }, +); + +describe("rtBigIntWords", () => { + it("is identity for any BigInt under the word-buffer cap", () => { + fc.assert( + fc.property(bigIntArbWords, (b) => mod.rtBigIntWords(b) === b), + { + numRuns: FUZZ_RUNS, + examples: loadSeeds("rtBigIntWords", reviveBigInt), + }, + ); + }); +}); diff --git a/tests/fuzz_numeric/mod.zig b/tests/fuzz/mod.zig similarity index 100% rename from tests/fuzz_numeric/mod.zig rename to tests/fuzz/mod.zig diff --git a/tests/fuzz/number.fuzz.test.ts b/tests/fuzz/number.fuzz.test.ts new file mode 100644 index 0000000..bc8cba0 --- /dev/null +++ b/tests/fuzz/number.fuzz.test.ts @@ -0,0 +1,77 @@ +import { describe, it } from "vitest"; +import fc from "fast-check"; +import { edgeNumbers } from "../utils/edge.ts"; +import { + oracleNumberF64, + oracleNumberI32, + oracleNumberI64, + oracleNumberU32, +} from "../utils/oracles.ts"; +import { FUZZ_RUNS, loadSeeds, mod } from "../utils/support.ts"; + +const numberArb = fc.oneof( + { arbitrary: fc.double(), weight: 4 }, + { arbitrary: fc.constantFrom(...edgeNumbers), weight: 1 }, +); + +describe("rtNumberF64", () => { + it("round-trip is identity for all JS numbers", () => { + fc.assert( + fc.property(numberArb, (n) => { + const result = mod.rtNumberF64(n); + const expected = oracleNumberF64(n); + if (Number.isNaN(expected)) return Number.isNaN(result); + return Object.is(result, expected); + }), + { + numRuns: FUZZ_RUNS, + examples: loadSeeds("rtNumberF64", (raw) => raw as number), + }, + ); + }); +}); + +const numberIntArb = fc.oneof( + { arbitrary: fc.integer({ min: -(2 ** 33), max: 2 ** 33 }), weight: 3 }, + { arbitrary: fc.double(), weight: 2 }, + { arbitrary: fc.constantFrom(...edgeNumbers), weight: 1 }, +); + +describe("rtNumberI32", () => { + it("matches ECMAScript ToInt32 (`| 0`)", () => { + fc.assert( + fc.property(numberIntArb, (n) => mod.rtNumberI32(n) === oracleNumberI32(n)), + { + numRuns: FUZZ_RUNS, + examples: loadSeeds("rtNumberI32", (r) => r as number), + }, + ); + }); +}); + +describe("rtNumberU32", () => { + it("matches ECMAScript ToUint32 (`>>> 0`)", () => { + fc.assert( + fc.property(numberIntArb, (n) => mod.rtNumberU32(n) === oracleNumberU32(n)), + { + numRuns: FUZZ_RUNS, + examples: loadSeeds("rtNumberU32", (r) => r as number), + }, + ); + }); +}); + +describe("rtNumberI64", () => { + it("matches NAPI int64 semantics (clamped)", () => { + fc.assert( + fc.property( + numberIntArb, + (n) => mod.rtNumberI64(n) === oracleNumberI64(n), + ), + { + numRuns: FUZZ_RUNS, + examples: loadSeeds("rtNumberI64", (r) => r as number), + }, + ); + }); +}); diff --git a/tests/fuzz_numeric/oracle.test.ts b/tests/fuzz/oracle.test.ts similarity index 66% rename from tests/fuzz_numeric/oracle.test.ts rename to tests/fuzz/oracle.test.ts index 89fd6a4..9f81852 100644 --- a/tests/fuzz_numeric/oracle.test.ts +++ b/tests/fuzz/oracle.test.ts @@ -1,6 +1,5 @@ import { describe, it, expect } from "vitest"; -import { createRequire } from "node:module"; -import { edgeNumbers, edgeBigInts, edgeUint8Arrays } from "./edges.ts"; +import { edgeNumbers, edgeBigInts, edgeUint8Arrays } from "../utils/edge.ts"; import { equalUint8Array, fitsBigIntI128, @@ -15,17 +14,8 @@ import { oracleNumberI64, oracleNumberU32, oracleUint8Array, -} from "./oracles.ts"; - -const require = createRequire(import.meta.url); -const mod = require("../../zig-out/lib/test_fuzz_numeric.node") as { - rtNumberF64(n: number): number; - rtNumberI32(n: number): number; - rtNumberU32(n: number): number; - rtNumberI64(n: number): bigint; - rtBigIntI64(b: bigint): bigint; - rtBigIntU64(b: bigint): bigint; -}; +} from "../utils/oracles.ts"; +import { mod } from "../utils/support.ts"; function describeValue(v: number): string { if (Number.isNaN(v)) return "NaN"; @@ -95,15 +85,10 @@ describe("oracle sanity: rtBigIntU64", () => { } }); -const modL = require("../../zig-out/lib/test_fuzz_numeric.node") as { - losslessI64(b: bigint): { value: bigint; lossless: boolean }; - losslessU64(b: bigint): { value: bigint; lossless: boolean }; -}; - describe("oracle sanity: losslessI64", () => { for (const b of edgeBigInts) { it(`agrees with oracle on ${b}n`, () => { - expect(modL.losslessI64(b)).toEqual(oracleLosslessI64(b)); + expect(mod.losslessI64(b)).toEqual(oracleLosslessI64(b)); }); } }); @@ -111,23 +96,18 @@ describe("oracle sanity: losslessI64", () => { describe("oracle sanity: losslessU64", () => { for (const b of edgeBigInts) { it(`agrees with oracle on ${b}n`, () => { - expect(modL.losslessU64(b)).toEqual(oracleLosslessU64(b)); + expect(mod.losslessU64(b)).toEqual(oracleLosslessU64(b)); }); } }); -const modI128 = require("../../zig-out/lib/test_fuzz_numeric.node") as { - rtBigIntI128(b: bigint): bigint; - rtBigIntI128LowBits(b: bigint): bigint; -}; - describe("oracle sanity: rtBigIntI128", () => { for (const b of edgeBigInts) { it(`is exact-or-throw on ${b}n`, () => { if (fitsBigIntI128(b)) { - expect(modI128.rtBigIntI128(b)).toBe(oracleBigIntI128(b)); + expect(mod.rtBigIntI128(b)).toBe(oracleBigIntI128(b)); } else { - expect(() => modI128.rtBigIntI128(b)).toThrow(); + expect(() => mod.rtBigIntI128(b)).toThrow(); } }); } @@ -135,55 +115,50 @@ describe("oracle sanity: rtBigIntI128", () => { describe("rtBigIntI128 boundary cases", () => { it("0n → 0n (zero)", () => { - expect(modI128.rtBigIntI128(0n)).toBe(0n); + expect(mod.rtBigIntI128(0n)).toBe(0n); }); it("I128_MIN = -(1n << 127n) → -(1n << 127n) (in-range lower bound)", () => { const b = -(1n << 127n); - expect(modI128.rtBigIntI128(b)).toBe(b); + expect(mod.rtBigIntI128(b)).toBe(b); }); it("I128_MAX = (1n << 127n) - 1n → (1n << 127n) - 1n (in-range upper bound)", () => { const b = (1n << 127n) - 1n; - expect(modI128.rtBigIntI128(b)).toBe(b); + expect(mod.rtBigIntI128(b)).toBe(b); }); it("1n << 127n (just out-of-range positive) throws", () => { const b = 1n << 127n; - expect(() => modI128.rtBigIntI128(b)).toThrow(); + expect(() => mod.rtBigIntI128(b)).toThrow(); }); it("-(1n << 127n) - 1n (just out-of-range negative) throws", () => { const b = -(1n << 127n) - 1n; - expect(() => modI128.rtBigIntI128(b)).toThrow(); + expect(() => mod.rtBigIntI128(b)).toThrow(); }); it("1n << 128n (oversized positive) throws", () => { - expect(() => modI128.rtBigIntI128(1n << 128n)).toThrow(); + expect(() => mod.rtBigIntI128(1n << 128n)).toThrow(); }); it("-(1n << 128n) (oversized negative) throws", () => { - expect(() => modI128.rtBigIntI128(-(1n << 128n))).toThrow(); + expect(() => mod.rtBigIntI128(-(1n << 128n))).toThrow(); }); }); describe("oracle sanity: rtBigIntI128LowBits", () => { for (const b of edgeBigInts) { it(`agrees with low-bits oracle on ${b}n`, () => { - expect(modI128.rtBigIntI128LowBits(b)).toBe(oracleBigIntI128LowBits(b)); + expect(mod.rtBigIntI128LowBits(b)).toBe(oracleBigIntI128LowBits(b)); }); } }); -const modW = require("../../zig-out/lib/test_fuzz_numeric.node") as { - rtBigIntWords(b: bigint): bigint; - rtUint8Array(value: Uint8Array): Uint8Array; -}; - describe("oracle sanity: rtBigIntWords", () => { for (const b of edgeBigInts) { it(`round-trips ${b}n`, () => { - expect(modW.rtBigIntWords(b)).toBe(b); + expect(mod.rtBigIntWords(b)).toBe(b); }); } }); @@ -191,7 +166,7 @@ describe("oracle sanity: rtBigIntWords", () => { describe("oracle sanity: rtUint8Array", () => { for (const value of edgeUint8Arrays) { it(`round-trips ${value.length} byte(s)`, () => { - expect(equalUint8Array(modW.rtUint8Array(value), oracleUint8Array(value))).toBe( + expect(equalUint8Array(mod.rtUint8Array(value), oracleUint8Array(value))).toBe( true, ); }); diff --git a/tests/fuzz_numeric/seeds/.gitkeep b/tests/fuzz/seeds/.gitkeep similarity index 100% rename from tests/fuzz_numeric/seeds/.gitkeep rename to tests/fuzz/seeds/.gitkeep diff --git a/tests/fuzz/uint8array.fuzz.test.ts b/tests/fuzz/uint8array.fuzz.test.ts new file mode 100644 index 0000000..23909c9 --- /dev/null +++ b/tests/fuzz/uint8array.fuzz.test.ts @@ -0,0 +1,29 @@ +import { describe, it } from "vitest"; +import fc from "fast-check"; +import { edgeUint8Arrays } from "../utils/edge.ts"; +import { equalUint8Array, oracleUint8Array } from "../utils/oracles.ts"; +import { + FUZZ_RUNS, + loadSeeds, + mod, + reviveUint8Array, +} from "../utils/support.ts"; + +const uint8ArrayArb = fc.oneof( + { arbitrary: fc.uint8Array({ maxLength: 4096 }), weight: 4 }, + { arbitrary: fc.constantFrom(...edgeUint8Arrays), weight: 1 }, +); + +describe("rtUint8Array", () => { + it("round-trips bytes unchanged", () => { + fc.assert( + fc.property(uint8ArrayArb, (value) => + equalUint8Array(mod.rtUint8Array(value), oracleUint8Array(value)), + ), + { + numRuns: FUZZ_RUNS, + examples: loadSeeds("rtUint8Array", reviveUint8Array), + }, + ); + }); +}); diff --git a/tests/fuzz_numeric/fuzz.test.ts b/tests/fuzz_numeric/fuzz.test.ts deleted file mode 100644 index a30589f..0000000 --- a/tests/fuzz_numeric/fuzz.test.ts +++ /dev/null @@ -1,296 +0,0 @@ -import { describe, it } from "vitest"; -import { createRequire } from "node:module"; -import * as fs from "node:fs"; -import * as path from "node:path"; -import * as url from "node:url"; -import fc from "fast-check"; -import { edgeNumbers, edgeBigInts, edgeUint8Arrays } from "./edges.ts"; -import { - equalUint8Array, - fitsBigIntI128, - fitsBigIntI64, - fitsBigIntU64, - oracleBigIntI128, - oracleBigIntI128LowBits, - oracleLosslessI64, - oracleLosslessU64, - oracleNumberF64, - oracleNumberI32, - oracleNumberI64, - oracleNumberU32, - oracleUint8Array, -} from "./oracles.ts"; - -const require = createRequire(import.meta.url); -const mod = require("../../zig-out/lib/test_fuzz_numeric.node") as { - rtNumberF64(n: number): number; - rtNumberI32(n: number): number; - rtNumberU32(n: number): number; - rtNumberI64(n: number): bigint; - rtBigIntI64(b: bigint): bigint; - rtBigIntU64(b: bigint): bigint; - losslessI64(b: bigint): { value: bigint; lossless: boolean }; - losslessU64(b: bigint): { value: bigint; lossless: boolean }; - rtBigIntI128(b: bigint): bigint; - rtBigIntI128LowBits(b: bigint): bigint; - rtBigIntWords(b: bigint): bigint; - rtUint8Array(value: Uint8Array): Uint8Array; -}; - -const FUZZ_RUNS = Number(process.env.FUZZ_RUNS ?? 10_000); -const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); - -/** - * Load persisted regression cases for a target. - * - * Each file under seeds/ is a JSON array of values (in whatever JSON-encodable - * form makes sense for the target; bigints use `{"__bigint":"123"}`). - * Missing file → empty examples list; not an error. - */ -function loadSeeds(target: string, revive: (raw: unknown) => T): T[] { - const file = path.join(__dirname, "seeds", `${target}.json`); - if (!fs.existsSync(file)) return []; - const raw = JSON.parse(fs.readFileSync(file, "utf8")) as unknown[]; - return raw.map(revive); -} - -function reviveBigInt(raw: unknown): bigint { - if (typeof raw === "object" && raw !== null && "__bigint" in raw) { - return BigInt((raw as { __bigint: string }).__bigint); - } - throw new Error(`Cannot revive as bigint: ${JSON.stringify(raw)}`); -} - -function reviveUint8Array(raw: unknown): Uint8Array { - if (Array.isArray(raw) && raw.every((v) => Number.isInteger(v))) { - return new Uint8Array(raw); - } - throw new Error(`Cannot revive as Uint8Array: ${JSON.stringify(raw)}`); -} - -const numberArb = fc.oneof( - { arbitrary: fc.double(), weight: 4 }, - { arbitrary: fc.constantFrom(...edgeNumbers), weight: 1 }, -); - -describe("rtNumberF64", () => { - it("round-trip is identity for all JS numbers", () => { - fc.assert( - fc.property(numberArb, (n) => { - const result = mod.rtNumberF64(n); - const expected = oracleNumberF64(n); - if (Number.isNaN(expected)) return Number.isNaN(result); - return Object.is(result, expected); - }), - { - numRuns: FUZZ_RUNS, - examples: loadSeeds("rtNumberF64", (raw) => raw as number), - }, - ); - }); -}); - -const numberIntArb = fc.oneof( - { arbitrary: fc.integer({ min: -(2 ** 33), max: 2 ** 33 }), weight: 3 }, - { arbitrary: fc.double(), weight: 2 }, - { arbitrary: fc.constantFrom(...edgeNumbers), weight: 1 }, -); - -describe("rtNumberI32", () => { - it("matches ECMAScript ToInt32 (`| 0`)", () => { - fc.assert( - fc.property(numberIntArb, (n) => mod.rtNumberI32(n) === oracleNumberI32(n)), - { - numRuns: FUZZ_RUNS, - examples: loadSeeds("rtNumberI32", (r) => r as number), - }, - ); - }); -}); - -describe("rtNumberU32", () => { - it("matches ECMAScript ToUint32 (`>>> 0`)", () => { - fc.assert( - fc.property(numberIntArb, (n) => mod.rtNumberU32(n) === oracleNumberU32(n)), - { - numRuns: FUZZ_RUNS, - examples: loadSeeds("rtNumberU32", (r) => r as number), - }, - ); - }); -}); - -describe("rtNumberI64", () => { - it("matches NAPI int64 semantics (clamped)", () => { - fc.assert( - fc.property( - numberIntArb, - (n) => mod.rtNumberI64(n) === oracleNumberI64(n), - ), - { - numRuns: FUZZ_RUNS, - examples: loadSeeds("rtNumberI64", (r) => r as number), - }, - ); - }); -}); - -const bigIntArbI64 = fc.oneof( - { arbitrary: fc.bigInt({ min: -(2n ** 65n), max: 2n ** 65n }), weight: 4 }, - { arbitrary: fc.constantFrom(...edgeBigInts), weight: 1 }, -); - -describe("rtBigIntI64", () => { - it("is exact in [-2^63, 2^63) and throws outside that range", () => { - fc.assert( - fc.property(bigIntArbI64, (b) => { - if (fitsBigIntI64(b)) return mod.rtBigIntI64(b) === b; - - try { - mod.rtBigIntI64(b); - return false; - } catch { - return true; - } - }), - { - numRuns: FUZZ_RUNS, - examples: loadSeeds("rtBigIntI64", reviveBigInt), - }, - ); - }); -}); - -describe("rtBigIntU64", () => { - it("is exact in [0, 2^64) and throws outside that range", () => { - fc.assert( - fc.property(bigIntArbI64, (b) => { - if (fitsBigIntU64(b)) return mod.rtBigIntU64(b) === b; - - try { - mod.rtBigIntU64(b); - return false; - } catch { - return true; - } - }), - { - numRuns: FUZZ_RUNS, - examples: loadSeeds("rtBigIntU64", reviveBigInt), - }, - ); - }); -}); - -describe("losslessI64", () => { - it("lossless ⇔ b ∈ [-2^63, 2^63) and value matches asIntN", () => { - fc.assert( - fc.property(bigIntArbI64, (b) => { - const { value, lossless } = mod.losslessI64(b); - const expected = oracleLosslessI64(b); - return value === expected.value && lossless === expected.lossless; - }), - { - numRuns: FUZZ_RUNS, - examples: loadSeeds("losslessI64", reviveBigInt), - }, - ); - }); -}); - -describe("losslessU64", () => { - it("lossless ⇔ b ∈ [0, 2^64) and value matches asUintN", () => { - fc.assert( - fc.property(bigIntArbI64, (b) => { - const { value, lossless } = mod.losslessU64(b); - const expected = oracleLosslessU64(b); - return value === expected.value && lossless === expected.lossless; - }), - { - numRuns: FUZZ_RUNS, - examples: loadSeeds("losslessU64", reviveBigInt), - }, - ); - }); -}); - -const bigIntArbI128 = fc.oneof( - { arbitrary: fc.bigInt({ min: -(2n ** 129n), max: 2n ** 129n }), weight: 4 }, - { arbitrary: fc.constantFrom(...edgeBigInts), weight: 1 }, -); - -describe("rtBigIntI128", () => { - it("is exact in [-2^127, 2^127) and throws outside that range", () => { - fc.assert( - fc.property(bigIntArbI128, (b) => { - if (fitsBigIntI128(b)) return mod.rtBigIntI128(b) === oracleBigIntI128(b); - - try { - mod.rtBigIntI128(b); - return false; - } catch { - return true; - } - }), - { - numRuns: FUZZ_RUNS, - examples: loadSeeds("rtBigIntI128", reviveBigInt), - }, - ); - }); -}); - -describe("rtBigIntI128LowBits", () => { - it("matches BigInt.asIntN(128, ·)", () => { - fc.assert( - fc.property( - bigIntArbI128, - (b) => mod.rtBigIntI128LowBits(b) === oracleBigIntI128LowBits(b), - ), - { - numRuns: FUZZ_RUNS, - examples: loadSeeds("rtBigIntI128LowBits", reviveBigInt), - }, - ); - }); -}); - -const bigIntArbWords = fc.oneof( - // Keep within the addon's 64-word cap (4096 bits). - { - arbitrary: fc.bigInt({ min: -(2n ** 4000n), max: 2n ** 4000n }), - weight: 4, - }, - { arbitrary: fc.constantFrom(...edgeBigInts), weight: 1 }, -); - -describe("rtBigIntWords", () => { - it("is identity for any BigInt under the word-buffer cap", () => { - fc.assert( - fc.property(bigIntArbWords, (b) => mod.rtBigIntWords(b) === b), - { - numRuns: FUZZ_RUNS, - examples: loadSeeds("rtBigIntWords", reviveBigInt), - }, - ); - }); -}); - -const uint8ArrayArb = fc.oneof( - { arbitrary: fc.uint8Array({ maxLength: 4096 }), weight: 4 }, - { arbitrary: fc.constantFrom(...edgeUint8Arrays), weight: 1 }, -); - -describe("rtUint8Array", () => { - it("round-trips bytes unchanged", () => { - fc.assert( - fc.property(uint8ArrayArb, (value) => - equalUint8Array(mod.rtUint8Array(value), oracleUint8Array(value)), - ), - { - numRuns: FUZZ_RUNS, - examples: loadSeeds("rtUint8Array", reviveUint8Array), - }, - ); - }); -}); diff --git a/tests/fuzz_numeric/edges.ts b/tests/utils/edge.ts similarity index 100% rename from tests/fuzz_numeric/edges.ts rename to tests/utils/edge.ts diff --git a/tests/fuzz_numeric/oracles.ts b/tests/utils/oracles.ts similarity index 100% rename from tests/fuzz_numeric/oracles.ts rename to tests/utils/oracles.ts diff --git a/tests/utils/support.ts b/tests/utils/support.ts new file mode 100644 index 0000000..6d4827a --- /dev/null +++ b/tests/utils/support.ts @@ -0,0 +1,54 @@ +import { createRequire } from "node:module"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as url from "node:url"; + +export type FuzzAddon = { + rtNumberF64(n: number): number; + rtNumberI32(n: number): number; + rtNumberU32(n: number): number; + rtNumberI64(n: number): bigint; + rtBigIntI64(b: bigint): bigint; + rtBigIntU64(b: bigint): bigint; + losslessI64(b: bigint): { value: bigint; lossless: boolean }; + losslessU64(b: bigint): { value: bigint; lossless: boolean }; + rtBigIntI128(b: bigint): bigint; + rtBigIntI128LowBits(b: bigint): bigint; + rtBigIntWords(b: bigint): bigint; + rtUint8Array(value: Uint8Array): Uint8Array; +}; + +const require = createRequire(import.meta.url); + +export const mod = require("../../zig-out/lib/test_fuzz_numeric.node") as FuzzAddon; +export const FUZZ_RUNS = Number(process.env.FUZZ_RUNS ?? 10_000); + +const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); +const seedsDir = path.join(__dirname, "../fuzz/seeds"); + +/** + * Load persisted regression cases for a target. + * + * Each file under tests/fuzz/seeds/ is a JSON array of values. + * Missing file -> empty examples list; not an error. + */ +export function loadSeeds(target: string, revive: (raw: unknown) => T): T[] { + const file = path.join(seedsDir, `${target}.json`); + if (!fs.existsSync(file)) return []; + const raw = JSON.parse(fs.readFileSync(file, "utf8")) as unknown[]; + return raw.map(revive); +} + +export function reviveBigInt(raw: unknown): bigint { + if (typeof raw === "object" && raw !== null && "__bigint" in raw) { + return BigInt((raw as { __bigint: string }).__bigint); + } + throw new Error(`Cannot revive as bigint: ${JSON.stringify(raw)}`); +} + +export function reviveUint8Array(raw: unknown): Uint8Array { + if (Array.isArray(raw) && raw.every((v) => Number.isInteger(v))) { + return new Uint8Array(raw); + } + throw new Error(`Cannot revive as Uint8Array: ${JSON.stringify(raw)}`); +} From 23674b463fdeb988e13501d8028804050ed14a34 Mon Sep 17 00:00:00 2001 From: Nazar Hussain Date: Fri, 29 May 2026 12:18:30 +0200 Subject: [PATCH 21/21] chore: rename fuzz tests script --- .github/workflows/ci.yml | 2 +- package.json | 2 +- tests/fuzz/README.md | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3995bd8..9529b52 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,7 +45,7 @@ jobs: - name: Build run: pnpm build - name: Fuzz - run: pnpm test:fuzz + run: pnpm test:fuzz:round_trip env: FUZZ_RUNS: "1000" diff --git a/package.json b/package.json index fc1be97..33bb9e8 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "build:zig": "zig build", "test:zig": "zig build test:zapi", "test:js": "vitest run examples/**/*.test.ts", - "test:fuzz": "pnpm build:zig && vitest run tests/**/*.test.ts", + "test:fuzz:round_trip": "pnpm build:zig && vitest run tests/**/*.test.ts", "test": "pnpm test:zig && pnpm test:js", "lint:zig": "zig fmt src", "lint:js": "pnpm biome check", diff --git a/tests/fuzz/README.md b/tests/fuzz/README.md index a82ebd9..67cac6b 100644 --- a/tests/fuzz/README.md +++ b/tests/fuzz/README.md @@ -8,10 +8,10 @@ See `docs/superpowers/specs/2026-05-28-fuzz-testing-design.md` for design ration ```bash # Full run (10 000 cases per property) -pnpm test:fuzz +pnpm test:fuzz:round_trip # CI-equivalent (1 000 cases per property) -FUZZ_RUNS=1000 pnpm test:fuzz +FUZZ_RUNS=1000 pnpm test:fuzz:round_trip # Single property pnpm vitest run tests/fuzz/bigint.fuzz.test.ts -t "rtBigIntI128" @@ -55,4 +55,4 @@ Once fixed, **persist the counterexample** so it runs forever: 2. Add the oracle to `oracle.test.ts` and run it against every entry in the relevant edge list. 3. Add the fast-check property to the relevant `*.fuzz.test.ts` file. 4. Update `tests/utils/edge.ts` if the target needs new edge values. -5. Run `pnpm test:fuzz` and watch it pass (or find a bug). +5. Run `pnpm test:fuzz:round_trip` and watch it pass (or find a bug).