Skip to content

test: add seeded differential fuzz oracles for node:path, Bun.Glob, source maps and the transpiler - #39006

Open
robobun wants to merge 7 commits into
mainfrom
farm/7700bfb4/differential-fuzz-oracles
Open

robobun wants to merge 7 commits into
mainfrom
farm/7700bfb4/differential-fuzz-oracles

Conversation

@robobun

@robobun robobun commented Aug 15, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • The differential fuzz scripts that found bugs during the Rust rewrite (Rewrite Bun in Rust #30412) never made it into the tree; on main the only seeded oracle CI runs is test/js/bun/jsonc/json-differential-fuzz.test.ts.
  • node:path, Bun.Glob, source map generation and the transpiler printer were all rewritten and only have example-based tests.
  • Writing the oracles below found five divergences on main straight away (list under Fix).

Fix

  • test/_util/fuzz.ts: the seed/iteration plumbing lifted out of the JSON fuzz (Rng, envSeed, envIters, fuzzEnv); json-differential-fuzz.test.ts imports it, same seed and counts as before.
    • every oracle reads BUN_<NAME>_FUZZ_SEED / BUN_<NAME>_FUZZ_ITERS; case N is the same however many cases a run asks for, and each failure message prints the two variables that replay it. A soak is the ITERS variable plus --timeout (documented in the helper); the tests themselves set no timeouts.
    • debug builds get a smaller default count (a debug JSC runs the generators and reference implementations 20-40x slower); release and ASAN lanes run the larger one.
  • test/js/node/path/path-differential-fuzz.test.ts: random posix and win32 paths (./.., repeated, trailing and mixed separators, empty segments, drive letters, UNC roots, non-ASCII) through normalize, join, resolve from fixed absolute bases, relative, dirname, basename with and without suffix, extname, parse, format(parse()), isAbsolute, toNamespacedPath, compared call for call with nodeExe().
  • test/js/bun/glob/glob-differential-fuzz.test.ts: patterns derived from a random path (*, ?, classes and ranges, nested braces, escapes, globstar segments, negation) run through Bun.Glob#match and picomatch against the path, mutations of it and unrelated paths.
    • picomatch 4.0.4 is added to test/package.json; it was already in the lockfile as a transitive dependency, and the isolated linker only exposes direct ones. scan.test.ts's snapshot of this directory's listing gains the new file.
    • the header documents each dialect difference the generator stays out of (for instance picomatch lets a/**/{,b} match a, reads a ** inside or next to a brace group as slash-crossing where Bun's bash-style expansion does not, and gives . followed by * inside braces its dotfile guard).
  • test/bundler/sourcemap-differential-fuzz.test.ts: random multi-module programs with random layout, comments, non-ASCII text and \r\n, built with Bun.build({ sourcemap: "external" }) as esm, iife, cjs and whitespace-minified esm/iife, decoded with source-map 0.7.4.
    • every mapping must land inside its original file, a generated position may not map to two originals, sourcesContent must be verbatim, and every output identifier that has a mapping must map to that same identifier in the original (identifiers are unique across the run, so the check is exact); a name, should Bun start emitting them, must be that identifier.
    • a floor on the number of identifiers checked keeps the test from going vacuous.
  • test/bundler/transpiler/transpiler-differential-fuzz.test.ts: random programs from a small grammar (declarations, functions with default and rest parameters, arrows, classes, destructuring, loops, try, switch, literals with holes, spread, getters and computed keys, templates, optional chaining, ??, every operator), with every compound expression parenthesized in the source so the printer has to decide which parentheses the output needs.
    • plain and whitespace-minified output must parse with acorn 8.15, be a fixed point under a second transform, and push the same values to __out as the source when run.
    • programs are strict-mode clean, terminate and never throw, so values are compared rather than exception names.
  • Divergences found on main while writing these. Each is handed off separately (fixes are in flight for all of them), excluded in the generator, and pinned with a test.failing next to the exclusion; the pin starts passing when the fix lands, and its comment says which exclusion to delete, so the generators cannot stay narrowed silently:
    • Bun.Glob: a**/** matches "ab" although a*/** does not (src/glob/matcher.rs collapses the /** before checking that the ** is a globstar).
    • Bun.Glob: **/a**/{x,y} matches "ab/ay" (same family, different path through the matcher). One pin for both, since the gating can only go once both are fixed.
    • Bun.build: with several entrypoints BuildArtifact.sourcemap points at the wrong artifact; outputs are [js0, js1, map0, map1] but js_bundle_completion_task.rs attaches the next output. The oracle pairs maps by path until the pin flips, then should read js.sourcemap.
    • transpiler: "a" != Infinity prints as "a" != 1 / 0, which reprints as 1 / 0 != "a" (print_number plus the constants-on-the-right reordering).
    • transpiler: unused (a, b) ? f() : 0; prints as a, b && f(); and only the next pass drops the a, (simplify_unused_expr). The two transpiler pins run the excluded programs through the oracle's own check().
    • node:path: basename("//", "日本") is "//" in node and "" on main (suffix length compared in UTF-8 bytes rather than UTF-16 units); node:path: rewrite on JSString storage in Rust, match Node.js v26 exactly, ~3x faster #37305 fixes it, and its pin is the cue to widen the whole generator.
  • Additive change: no src edit, so there is no fail-before for the oracles themselves; the pins are the fail-before evidence (each asserts node's / picomatch's / the fixed-point answer and currently fails for exactly the reason listed above).
  • Verification: bun bd test differential-fuzz runs the five files (15 tests) in about 17 s on this debug+ASAN build, each oracle body 1-3 s; with the release binary the set takes under 2 s. The new files typecheck under test/tsconfig.json. CI build for the previous revision was green on every lane for these files.
  • Soaks with the release binary after the exclusions, all clean: glob 30 seeds x 20k patterns (1.8M matches); path 20 seeds x 20k iterations (9.6M calls); source maps 20k + 12k programs (about 2.4M mappings, 1M identifiers checked); transpiler 2 x 20k plus 12 runs of 3k-5k programs.

Background

  • A differential oracle generates inputs from a seeded PRNG, runs them through the code under test and through an independent reference, and compares exactly. The references here are node itself, picomatch, the Mozilla source-map decoder together with the text the generator wrote, and acorn plus JavaScriptCore running the program. The seed makes any failure reproducible, and the same test doubles as a soak by raising the count.
  • Fixed point: transpiling the transpiler's own output must reproduce it byte for byte. Both transpiler divergences are violations of this; harmless to users, but exactly the kind of printer/visitor disagreement that hides real bugs, so the property is asserted.
  • test.failing (docs/test/writing-tests.mdx) inverts a test: it passes while the body throws and fails once the body passes. It is the repo's way of tracking a known bug so that the fix is noticed; here each pin doubles as the instruction for widening the generator.
  • Where two dialects legitimately differ (picomatch's regex semantics vs Bun's bash-like braces, Node versions), the generator does not produce that class of input. The comparison itself is always exact, so nothing can absorb a divergence silently; each exclusion is commented where it is applied.

@coderabbitai

coderabbitai Bot commented Aug 15, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 25 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 068dee25-c936-451b-95ff-4a0a12fdf85f

📥 Commits

Reviewing files that changed from the base of the PR and between 732491c and 71f2fc8.

⛔ Files ignored due to path filters (2)
  • test/bun.lock is excluded by !**/*.lock
  • test/js/bun/glob/__snapshots__/scan.test.ts.snap is excluded by !**/*.snap
📒 Files selected for processing (7)
  • test/_util/fuzz.ts
  • test/bundler/sourcemap-differential-fuzz.test.ts
  • test/bundler/transpiler/transpiler-differential-fuzz.test.ts
  • test/js/bun/glob/glob-differential-fuzz.test.ts
  • test/js/bun/jsonc/json-differential-fuzz.test.ts
  • test/js/node/path/path-differential-fuzz.test.ts
  • test/package.json

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Aug 15, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: tests only, ready for review.

  • Each oracle was checked against the release binary and the debug+ASAN build (bun bd test differential-fuzz, 5 files / 15 tests, about 17 s on debug, under 2 s on release), then soaked against the release binary with the counts in the description; all clean after the marked exclusions.
  • The six divergences the oracles found on main are listed in the description. Each is tracked separately and pinned here with a test.failing next to its generator exclusion, so the fix that makes a pin pass is also told what to widen.
  • Self-review done; its points (pins instead of prose exclusions, no test timeouts, how this relates to node:path: rewrite on JSString storage in Rust, match Node.js v26 exactly, ~3x faster #37305) are addressed in 71f2fc8 and in the description.

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 4:46 AM PT - Aug 15th, 2026

⏳ @robobun, your commit 16bf772 is still building in Build #97969, but has 1 failures so far (All Failures):

…on; soaks pass --timeout instead of scaling it
@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

71f2fc8 changes since the first revision:

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this PR and didn't find any bugs. It's test-only, but it's ~1,700 lines of new fuzz generators across four subsystems, each with carefully-tuned dialect exclusions and test.failing pins — a human look at the generator constraints and CI-stability tradeoffs would still be worthwhile.

What was reviewed:

  • _util/fuzz.ts extraction is behavior-preserving for the existing JSON fuzz (same Rng, same seed/iters defaults).
  • Path oracle: cwd-independence (fixed absolute bases for resolve, device-carrying args for relative/toNamespacedPath, drive-relative only names the base's drive), skipIf on missing/wrong-major node, subprocess pipes drained concurrently.
  • Glob/transpiler/sourcemap oracles: seeded determinism, non-vacuous floor assertions, each test.failing pin sits next to the exclusion it justifies; picomatch was already in the lockfile transitively.
Extended reasoning...

Overview

This PR adds four new seeded differential-fuzz oracles (node:path vs a spawned node, Bun.Glob vs picomatch, Bun.build source maps vs mozilla source-map, and the transpiler vs acorn + itself), extracts the shared Rng/env-seed plumbing into test/_util/fuzz.ts, refactors the existing JSON fuzz to import it, adds picomatch as a direct test dependency, and updates the glob scan.test.ts snapshot for the new file. Every change is under test/; no src/ code is touched.

Security risks

None. Test-only additions; the only external process spawned is the local node binary via nodeExe() from harness (no network, no downloads — the PR explicitly avoids nodeExeMatchingAbi() for that reason). The added dependency (picomatch@4.0.4) was already present transitively in the lockfile.

Level of scrutiny

Medium-high. Although test-only and additive, this is a large change (~1,700 lines) with a lot of encoded domain knowledge: each generator carries a header of dialect differences that justify what it deliberately does not emit, and six test.failing pins that lock in known main-branch bugs. Getting an exclusion wrong in either direction means either a flaky CI test or a silently-narrowed oracle. The repo's review guidance treats test quality as a first-class blocking category, and the intricacy here (glob brace/globstar interaction rules, transpiler scope tracking to guarantee termination and no TDZ, win32 root generation that stays cwd-independent) is well past "mechanical".

Other factors

  • The PR description is unusually thorough, with soak counts and per-oracle timing on debug+ASAN vs release, and each exclusion is commented at the site — that reduces the review burden considerably.
  • Debug builds get a smaller default iteration count via isDebug; the tests set no per-test timeouts (soaks are meant to pass --timeout), which matches the guidance in CLAUDE.md.
  • The one CI failure noted in the timeline (test/bake/deinitialization.test.ts segfault on Windows) is unrelated to any file this PR touches.
  • The test.failing pins are the fail-before evidence for the six divergences found; each pin's comment names the generator gate to remove when it flips, so silent narrowing is guarded against — but a human should sanity-check that the pinned assertions actually reflect the intended reference behavior (e.g., the two transpiler pins go through the oracle's own check(), and the glob pin's expected value comes from picomatch itself).

Given the size and the amount of carefully-encoded exclusion logic, I'm deferring rather than approving so a maintainer can spot-check the dialect boundaries and the CI-time budget.

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

For whoever spot-checks the pins: this is what each one asserts and how it fails on main today (obtained by temporarily turning the test.failing into test), so the inversion is doing what it claims.

pin asserts (the reference's answer) on main right now
glob, a**/** vs ab and **/a**/{x,y} vs ab/ay Glob#match equals picomatch(...) for both, i.e. false first case returns true
sourcemap, two-entry build js.sourcemap.path === js.path + ".map" for each entry a.js gets ./b.js
transpiler, __out.push("a" != Infinity); oracle's check(): parses, fixed point, same behaviour fixed point fails: "a" != 1 / 0 reprints as 1 / 0 != "a"
transpiler, unused (a, b) ? f() : 0; same check() fixed point fails: a, b && f(); reprints as b && f();
path, basename("//", "日本") / win32.basename("z:/", "日本") node's "//" / "/" both return ""

Each pin's comment names the exclusion to delete when it flips: the partialGlobstar gating in derivePattern(), the by-path map lookup, the NUMBERS entries, the noComma flag, and the length check in genSuffix() (plus the wider list in that file's header, which #37305 covers; noted there too).

On time budget: on a debug+ASAN build the oracle bodies take roughly 1.6 s (source maps, 10 programs), 2.4 s (transpiler, 10 programs; almost all of it acorn and the programs themselves running under a debug JSC), 1.7 s (glob, 200 patterns) and 1.4 s (path, 100 iterations); on a release build the whole family is under 2 s with 5-30x more cases. The counts are the { release, debug } pair at the top of each file, so they are one-line knobs.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant