Fix four compressions that changed what the program does - #15
Merged
Conversation
Each of these produced output that parsed cleanly and then behaved differently,
which is the failure mode a minifier must not have - node --check passes, the
build is green, and the bug surfaces in a browser.
Template literals were re-emitted from their cooked value
TemplateCharacters.getValue() is the interpreted text, so putting it back
between backticks interpreted every escape a second time:
String.raw`\d+\.\d+` -> String.raw`d+.d+` regex stops matching
`price: \${sum}` -> `price: ${sum}` a live substitution
`a\\b` -> `a\b` length 3 becomes 2
`x\`y` -> `x`y` invalid output
getRawValue() carries the source text and is emitted instead. Substitutions are
unaffected; they are visited as expressions either way.
A free reference was never reserved
A name resolving to no declaration was dropped on the floor, so it never reached
ScriptOrFnScope.getUsedSymbols() and the munger handed the same name to a local:
function f(){var container="BODY";return a.init(container);}
-> function f(){var a="BODY";return a.init(a);} TypeError
The short-name pool starts at single letters, so every one- or two-character
global the file does not itself declare - "L", "$", "_", "d3" - collided with
the first local of any function. ScopeBuilder now reserves such names the same
way it already reserves a function's own name, up the scope chain.
The decision waits until the whole tree is built. A "var" or a function
declaration hoists over its own uses, so deciding at visit time would have
treated "function f(){use(x);var x=1;}" as free and stopped x being munged.
A destructuring default got no scope
declareVariableIdentifiers walks a pattern to declare its bindings, and the
VariableDeclaration branch returns without letting the generic traversal
descend - so a default value was never visited. A function expression in one
therefore had no ScriptOrFnScope, its own vars were never declared, and the
enclosing scope was free to munge a local onto them:
var {handler = function(){var a=100; return a+longName;}} = opts; var longName=5;
munged longName to "a", which the inner function's own "a" then shadowed: 105
became 200. Parameter defaults already worked - the generic traversal descends
into a FunctionNode's parameters - and are unchanged.
A bare integer lost the space before a member access
1 .toString() -> 1.toString() SyntaxError, exit code 0
"1..toString()" is the same length and needs no whitespace. The test is made
from the literal's own text: a first attempt scanned the output buffer
backwards for digits, which called "8e-5" a bare integer because the character
before its last digit is "-" and produced "8e-5..toFixed(3)". That regression
showed up on two real bundles in the corpus below, not in the unit tests, and
is pinned now. Numeric separators count as digits ("1_000." is a literal too);
"." , an exponent, a radix prefix and BigInt "n" all end the literal
unambiguously and are left alone.
Verification
- 1,247 real-world scripts compressed and every output run through node --check.
39 do not parse, all 39 identically before this change (they use Rhino-only
"for each" syntax their sources share). No new failure, no new second-pass
instability, and two files that previously failed to compress now succeed.
- Total output over the 576 that compress: +709 bytes on 11.4 MB (+0.01%). The
cost is concentrated in jquery-1.6.4.js, +686 bytes (0.7%), which is the price
of not munging a local onto a global; the size pins and the gap table are
updated.
- ModernJsTest.lineBreakNeverSplitsAStringLiteralAfterNestedSeparatorInsertions
was pinning the collision - its fixture's "a" is a free global and the inner
local was munged onto it. Expectation updated with a note.
- JsSemanticsTest executes the affected programs under node and compares output,
so the tests prove the behaviour rather than the spelling.
marevol
force-pushed
the
fix/js-correctness
branch
from
September 5, 2026 01:36
5aeb370 to
49cfa43
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Four compressions that produced output which parses cleanly and then behaves differently —
node --checkpasses, the build is green, and the bug surfaces in a browser.1. Template literals were re-emitted from their cooked value
TemplateCharacters.getValue()is the interpreted text, so putting it back between backticks interpreted every escape a second time.String.raw`\d+\.\d+`String.raw`d+.d+`re.test("3.14")flipstrue→false`price: \${sum}``price: ${sum}``a\\b``a\b`.length3 → 2 (\bis backspace)`x\`y``x`y`getRawValue()carries the source text and is emitted instead. Substitutions are unaffected — they are visited as expressions either way.2. A free reference was never reserved
A name resolving to no declaration was dropped, so it never reached
ScriptOrFnScope.getUsedSymbols()and the munger handed the same name to a local:The short-name pool starts at single letters, so every one- or two-character global the file does not itself declare —
L(Leaflet),$,_,d3— collided with the first local of any function.ScopeBuildernow reserves such names the same way it already reserves a function's own name (reserveNameUpChain).The decision waits until the whole tree is built: a
varor a function declaration hoists over its own uses, so deciding at visit time would have treatedfunction f(){use(x);var x=1;}as free and stoppedxbeing munged.3. A destructuring default got no scope
declareVariableIdentifierswalks a pattern to declare its bindings, and theVariableDeclarationbranch returns without letting the generic traversal descend — so a default value was never visited at all. A function expression in one had noScriptOrFnScope, its own vars were never declared, and the enclosing scope was free to munge a local onto them:longNamewas munged toa, which the inner function's ownathen shadowed — 105 became 200. Parameter defaults already worked (the generic traversal descends into aFunctionNode's parameters) and are unchanged.4. A bare integer lost the space before a member access
1..toString()is the same length and needs no whitespace.The test is made from the literal's own text. A first attempt scanned the output buffer backwards for digits, and that was wrong:
8e-5ends in a digit whose preceding character is-, so it was called a bare integer and became8e-5..toFixed(3), which no engine accepts. That regression appeared on two real bundles in the corpus below — not in the unit tests — and is pinned now. Numeric separators count as digits (1_000.is a literal too);., an exponent, a radix prefix and BigIntnall end the literal unambiguously and are left alone.Verification
node --check. 39 do not parse — all 39 identically before this change (they use Rhino-onlyfor eachsyntax that their sources share, so node rejects the sources too). No new failure, no new second-pass instability, and two files that previously failed to compress now succeed.jquery-1.6.4.js: +686 bytes (0.7%), the price of not munging a local onto a global. Size pins and the gap table are updated.ModernJsTest.lineBreakNeverSplitsAStringLiteralAfterNestedSeparatorInsertionswas pinning the collision — its fixture'sais a free global and the inner local was munged onto it. Expectation updated with a note saying so.JsSemanticsTest(new, 16 cases) executes the affected programs under node and compares output, so the tests prove the behaviour rather than the spelling; the string assertions stand alone when node is absent.node --check, and a call-trace differential against the original sources is identical.Full suite: 782 tests, 0 failures, 3 skipped.
Independent of #13 and #14; all three branch from
main.