Follow-up to #25594 (the ufuzz-style
semantic fuzzer). This extends tasks/minifier_fuzz with four more ways of asking
whether the minifier preserves meaning, and reports what they found.
Nothing here is a PR yet. Reproductions are included so each finding can be filed or dismissed independently.
Code: mizchi/oxc@feat/minifier-fuzz-expand
(tasks/minifier_fuzz)
The branch stacks on the three PRs already open — #25594 (the fuzzer), #25595 and
#25596 (the two fixes it found) — so only the top nine
commits
are new. Reviewing those in order tracks this report: the ** fix first, then
roughly one commit per fuzzing mode. The ** fix will be split into its own PR
rather than landing with the tooling.
| Mode | Oracle | Modelled on |
|---|---|---|
| default | run the program and the compressed program, compare observable behavior | Terser ufuzz.js |
--contexts |
one binding pattern bound in all 13 contexts that accept one; all must agree | esbuild destructuring-fuzzer.js |
--scopes |
three names reused down deep nested scopes; every read logs which binding it resolved to | — |
--corpus |
Terser's test/compress cases run through oxc, each compared with its own input |
esbuild terser-tests.js |
--invariants |
no Node.js: output parses and binds, a second pass never grows it, output is a fixed point | — |
--mangle applies to the first four. Mismatches are reduced by delta debugging
before being saved (--no-shrink disables it), so reproductions no longer need to
be minimised by hand.
The generator also gained scope tracking — generated code now reads the bindings it
declares, and an inner declaration shadows an outer one — plus let/const,
destructuring, labels, try/finally, for-in/for-of, generators, classes with
a private field and an accessor pair, named function expressions that reference
themselves, arrows, optional chaining, **, template literals,
typeof/delete/in/instanceof, default and rest parameters, arguments, and
closures captured in one place and called in another.
--scopes exists because the program generator is close to the easiest possible
input for a mangler: with every binding uniquely named, no reference can resolve
to the wrong one however the renaming goes, so --mangle had nothing to catch.
It draws from a pool of three names instead, reuses them down a deep chain of
nested scopes, and logs every read as the name it was written as paired with the
identity of what it resolved to. It also covers the places where a name is not
an ordinary reference and so must not be renamed with one — shorthand
properties, property keys, labels, private class fields, catch parameters,
named function expressions, and the scope a direct eval can see — plus scopes
of 80 bindings, which push the name allocator past the 54 single-character
identifiers into two-character ones.
Measured on the branch, so with the ** patch below applied. Run against main
the first campaign stops earlier, on that instead.
- generated programs — two mismatches, at seeds 2963 and 12397, both below.
Both reproduce with and without
--mangle. --contexts— seeds 0..49,999 with and without--mangle: all equivalent, 0 skipped.--scopes— seeds 0..29,999 with and without--mangle: all equivalent, 0 skipped, and 200,000 seeds satisfy the invariants. Nothing found.--corpus— 1274 runnable cases: 1101 equivalent, 155 do not complete under the sandbox, 0 rejected by the parser, 18 behavioral differences.--invariants— seeds 0..199,999 in release: no violation. 245 seeds are not a fixed point, together 1321 bytes a second pass would still remove (worst single seed: 425 bytes). In a debug build, roughly 7 seeds in 20,000 tripdebug_assert_no_under_prune.
None of these are fixed upstream. The first has a patch on the branch above, waiting to be split into its own PR; the rest have reproductions only.
f64::powf implements IEEE 754 pow, which returns 1 for a base of 1
whatever the exponent. The abstract operation returns NaN when the exponent is
NaN, and when the base has magnitude 1 and the exponent is infinite.
1 ** Infinity // spec: NaN oxc folded to: 1
(-1) ** Infinity // spec: NaN oxc folded to: 1
1 ** NaN // spec: NaN oxc folded to: 1
2 ** Infinity // Infinity (correct)1 is truthy and NaN is not, so an if guarded by such an expression takes the
wrong branch. The patch is one branch in
crates/oxc_ecmascript/src/constant_evaluation/mod.rs; just minsize shows no
snapshot change.
switch (1) {
case 0:
let v = 1;
default:
try { [0, v][0]; console.log("no throw"); } catch (e) { console.log("TDZ"); }
}Node prints TDZ. case 0 is never entered, so let v never runs, and reading
v must throw a ReferenceError. oxc drops the read as an unused array element
and prints no throw.
Constant propagation reaches the same root cause from the other side:
switch (1) {
case 0:
const v = 7;
default:
try { console.log(v); } catch (e) { console.log("TDZ", e.constructor.name); }
}
// oxc: console.log(7)Collapsing the constant switch into its default clause is correct in both.
What is not correct is treating a reference to a let/const binding as
side-effect free: until the declaration has run, evaluating it throws.
var xs = [];
console.log((+xs) - (xs.push(1), 0)); // 0
// oxc emits:
console.log(xs - (xs.push(1), 0)); // 1(+x) - y and x - y are interchangeable only when nothing observes when x
is coerced. ApplyStringOrNumericBinaryOperator evaluates both operands first
and calls ToNumeric afterwards, so in x - y the coercion of x happens after
y has run — and y here mutates x. The explicit + pins the coercion to
before y.
Found by the generated-program mode at seed 12397, once shadowing was added to the generator.
crates/oxc_minifier/src/compression_pass.rs:
incremental scoping under-prune: reference {idx} is still in a symbol's
resolved-references list but its node is gone from the program — a drop site
bypassed the `drop_*` / `replace_*` helpers, or the caller passed a `scoping`
inconsistent with `program`
About 7 of every 20,000 generated programs. It is behind debug_assertions, so
release campaigns never see it — --invariants catches panics and keeps going
rather than aborting the sweep. The smallest automatic reduction so far is 38
lines; happy to attach it.
Of the 18 differences, these look like genuine defects:
(0, o?.f)(x) loses the sequence wrapper, and with it the stripped this
binding (issue-t1372.js: issue_t1372_maintain_this_binding,
issue-t1371.js: issue_t1371_call_parentheses). The non-optional form
(0, o.f)(x) is preserved correctly, so only the optional-chaining path is
affected.
(function (o) {
console.log((0, o.f)("PASS"), (0, o?.f)("PASS"));
})({ a: "FAIL", f(b) { return this.a || b; } });
// oxc emits: console.log((0,o.f)("PASS"), (o?.f)("PASS")) -> second is "FAIL"A class name is dropped although .name observes it
(hoist_props.js: hoist_class). { p: class Foo {} } becomes { p: class {} },
so o.p.name changes from "Foo" to "p" by named evaluation.
delete (0/0) becomes delete NaN (evaluate.js: delete_expr_*,
sequences.js: delete_seq_*, conditionals.js: delete_conditional_*). Folding
the operand turns a value expression into a reference, so the result changes from
true to false.
var arguments plus inlining (collapse_vars.js: collapse_vars_arguments) —
an inner function's intrinsic arguments is replaced by an outer var arguments
after inlining.
Two more were not triaged and may well be intended: pure_globals.js: window_access_is_impure (a bare undeclared global read is removed, so a
try/catch around it never fires) and the two with-scope cases in
issue-1105.js.
The mangler came out clean. --scopes covers deep shadowing chains over a
three-name pool, direct eval (which oxc correctly stops from renaming anything
the eval can see), labels sharing a name with a binding, private class fields,
shorthand properties, catch parameters, named function expressions, and scopes
wide enough to force two-character generated names. 30,000 seeds with --mangle
and 200,000 invariant seeds found nothing.
That is a real negative result rather than an untested area: mangling was verified to actually happen on every seed, and the allocator was verified to emit 53 single-character names followed by two-character ones on a wide scope.
--contexts is likewise clean over 50,000 seeds, so binding patterns are bound
identically in all thirteen contexts that accept one.
oxc_minifier deliberately treats the ToPrimitive that ==, != and the
relational operators perform on an object operand as side-effect free. The cases
are pinned in crates/oxc_minifier/tests/ecmascript/may_have_side_effects.rs
with the comment "These actually have a side effect, but this treated as
side-effect free". + is handled correctly, so the asymmetry is deliberate.
This is easy to mistake for a bug when building a differential fuzzer. The
generator's valueOf/toString probes are therefore deliberately silent — a
loud one reports the accepted trade-off on nearly every seed and drowns out
everything else.
async/await and promises. vm.runInNewContext returns before microtasks
settle, so the oracle would need a drain step first.
None of these modes are wired into CI. Campaigns are long-running and a mismatch needs a human to look at it, so they are manual tools.
Tooling written with AI assistance (Claude Code); reviewed, and every finding above verified by hand against Node.js.