Skip to content

Fix silent output corruption in modern CSS and JavaScript, and build a regression safety net - #12

Merged
marevol merged 49 commits into
mainfrom
worktree-release1-modern-css-js
Sep 4, 2026
Merged

Fix silent output corruption in modern CSS and JavaScript, and build a regression safety net#12
marevol merged 49 commits into
mainfrom
worktree-release1-modern-css-js

Conversation

@marevol

@marevol marevol commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Summary

Release 1 of the modernisation work: correctness fixes for modern CSS and JavaScript, plus a test suite strong enough to detect regressions in the larger Release 2.

The theme throughout is replacing silent output corruption with either correct output or a loud failure. Most defects fixed here produced output that parsed cleanly and behaved differently from the source.

Java 11 remains the floor, and the public API is unchanged — JavaScriptCompressor(Reader, ErrorReporter), its 6-arg and 8-arg compress(...), CssCompressor(Reader) and its 2-arg compress(...) all keep byte-identical signatures, so downstream callers need no change.

Fixed — JavaScript

  • Unhandled AST nodes silently lost name munging. The toSource() fallback re-emitted a subtree's original source, so identifiers kept their old names while their declarations were renamed — the code then read and wrote globals. Affected ??, ??=, ||=, &&=, **= and debugger. A strict mode (system property) now makes unhandled node types fail loudly instead, and the remaining unhandled types are enumerated in the javadoc.
  • Optional chaining was destroyed, then over-widened; now decided from the source text rather than from Rhino's over-reporting.
  • Shorthand properties were munged as property keys. function f({b}){return b;} called with {b:7} returned undefined. Also fixed for shorthand carrying a default ({b = 1}) in all three positions — destructuring assignment, destructured parameter, and var destructuring.
  • Rest and default parameters were silently dropped. f(...args) became f(args), changing both behaviour and f.length.
  • Trailing array elisions changed array length: [, , a, , ] emitted as [,,b,].
  • --line-break no longer splits identifiers or string literals.
  • yield*, labeled statements, optional catch binding, generator object methods, template and regex literals, and three operator-merge hazards (including - -a becoming --a).
  • eval and with again suppress munging, restoring the guarantee the README has always made.
  • Comment-injection hazards for // and Annex B <!--.

Fixed — CSS

  • Comment collection was context-free. It scanned for /* without knowing whether it was inside a string or a URL. An unbalanced /*content:"/*" is valid CSS — replaced everything to end-of-input with an internal placeholder, truncating the stylesheet at exit 0. Comment collection, the --line-break pass and the at-rule/declaration boundary tests now share one region model.
  • @property, @charset and the at-directive lowercasing pass matched context-free, so text inside a url() could disable minification of the following rule, case-fold a case-sensitive URL path, or hoist an invented @charset to the top of the stylesheet.
  • Custom property values are preserved verbatim, including after a preserved token.
  • White space inside a non-base64 data: URL is no longer deleted — this was corrupting inline SVG (viewBox='0 0 24 24' became viewBox='002424'). Base64 payloads are still joined, per RFC 2397.
  • At-rule preludes are no longer function-tokenised; empty @layer blocks are kept (they are cascade-meaningful) while other empty at-rules are still removed.
  • Zero <time> values keep their units. CSS Values and Units Level 3 grants the unitless zero to <length> only, so transition-duration: 0 would be invalid.

Tests and tooling

  • Suite grows from 163 to 526 tests. 66 golden fixture pairs that existed on disk but were never loaded are now executed.
  • Differential execution: selected scripts run under node twice, as source and as compressed, comparing stdout. node --check proves output parses; this proves it still means the same thing. It found a silent corruption on its first run.
  • A missing node skips honestly and reports the count; a broken or hanging node now fails the build instead of silently disabling the checks.
  • Migrated to JUnit 5; dependency and build-plugin versions raised; Ant and Travis remnants removed.

Compression

jQuery 1.6.4 output improves from 137,798 to 104,770 bytes (−24.0%), caused mainly by a scope-traversal gap that left most nested functions unmunged. All 61 executed CSS golden fixtures are unchanged, and no golden file was edited in this branch.

Known limitations carried to Release 2

Documented in the CHANGELOG with reproductions and pinned by tests so they cannot be "fixed" from one end without the other becoming visible:

  • calc( inside a string is preserved by one pass and rewritten by another. Fixing either end alone relocates the corruption rather than removing it.
  • An escaped quote in a selector (as Tailwind's arbitrary-value classes emit) opens a phantom string region. The reachable form leaves a span unminified; the destroying forms need a comment or } inside a later string.
  • Several malformed-input cases where a comment survives into the output rather than being stripped.
  • Upstream optimisations still absent: ;} stripping, string-literal merging, quote-character choice, and short-name allocation quality — together about 2.5 KB on jQuery.

Verification

mvn -o clean package: BUILD SUCCESS, 526 tests, 0 failures, 3 skipped. The skips are class-syntax cases Rhino cannot parse at any version, labelled with an accurate reason.

The repository contains 71 source/expected fixture pairs under
src/test/resources, and its README states that they are discovered and run
automatically, but no such test existed. 66 of them compress with the default
options and are now wired up; 62 pass and become regression tests.

The five fixtures whose names begin with an underscore need non-default options
and are left out.

Four fixtures do not match current output and are quarantined in KNOWN_FAILURES
so the rest can protect the upcoming changes:
- zeros.css emits a zero time value with a unit where the golden expects 0
- issue86.js emits a bare number before .toString() where the golden parenthesises it
- promise-catch-finally-issue203.js does not munge callback parameters
- jquery-1.6.4.js is 137795 bytes against a golden of 101992 bytes
Add CliOptionTest to pin the externally visible behaviour of the
compressor options (munge/nomunge, global name preservation, /*! ... */
license comment retention, ordinary comment stripping, CSS line-break
handling) so later refactoring cannot change them silently.

Add JsOutputSyntaxTest to feed the compressed output of every JS
fixture to "node --check", catching output that is not valid
JavaScript at all -- a class of defect an expected-value comparison
can never catch for a case nobody thought of. Skips cleanly via
@EnabledIf when Node is unavailable so `mvn test` stays Node-independent.
catch (Exception) around both parsing and code generation let a crash
during the munging/output pass (NPE, index-out-of-bounds) be silently
swallowed as if it were an unparsable-input case, letting the test
pass with zero assertions. Split the two phases: only the
JavaScriptCompressor constructor's EvaluatorException (unparsable
input) is caught and excused; compress() runs outside the try so a
codegen crash propagates as a real test error, as it should when the
class of jquery-1.6.4.js's size and complexity are the fixtures this
test exists to protect.
Update build plugins to latest stable 3.x versions:
- maven-compiler-plugin: 3.11.0 → 3.16.0
- maven-shade-plugin: 3.2.1 → 3.6.2
- maven-jar-plugin: 3.1.0 → 3.5.1
- maven-javadoc-plugin: 3.11.2 → 3.12.0

All versions maintain Java 11 compatibility and have been verified
to build successfully with no test failures (236 tests, 0 failures,
2 skipped). The shaded jar continues to work correctly for both CSS
and JavaScript minification.

Java 11 target version remains unchanged.
Remove obsolete build artifacts:
- Delete .travis.yml (Travis CI config for old upstream)
- Delete build.xml and ant.properties (Ant build configuration)
- Remove Travis badge from README.md

Update documentation to reflect current CI setup:
- Update BUILDING.md to document GitHub Actions instead of Travis CI
- Keep "Migration from Ant to Maven" section in README.md (explains project history)
- Confirm GitHub Actions workflow uses v4 actions and Java 11/17/21

The project has been using Maven and GitHub Actions since the previous release.
This cleanup removes misleading legacy build files and outdated CI documentation.
Removing the whitespace before '(' turns an identifier into a function token
per CSS Syntax Level 3, which produced invalid output for
'@container <name> (...)', '@supports ... not (...)' and '@scope ... to (...)'.

The narrow patch that used to restore this for the single keyword 'and' also
lowercased it, so it is replaced by an explicit keyword-case normalisation
that leaves whitespace untouched (inserting a space here would break
functional pseudo-classes like ':not(...)').
The colour optimiser rewrites values by plain text substitution without looking
at the property name, so it turned a custom property holding a hex colour into a
named colour and rewrote @Property initial-value descriptors. A custom property
value is an arbitrary token stream and has to survive unchanged.

- Preserve custom property declaration values (--name: ...;) as an opaque
  token, after string/URL/calc preservation so a quote or semicolon inside an
  already-preserved token cannot be mistaken for the end of the value.
- Preserve every @Property at-rule block verbatim the same way, in the same
  layer, right before the custom-property scan. Running it there (rather than
  on raw source) means "@Property" mentioned inside a comment can never be
  matched, since the comment is already an opaque placeholder by then, and a
  brace inside a descriptor string (e.g. syntax: "}") can never be misread as
  the block's own closing brace, since the string is opaque too. It also
  means the stylesheet-global and/or/not keyword regex can't case-fold text
  like NOT(x) inside a custom property value, since by the time that regex
  runs the value is already an opaque placeholder.
- Because both scanners run after string preservation, a captured value can
  itself contain a placeholder reference to an earlier-preserved token (e.g.
  --content: "a;b";, or an @Property syntax descriptor's quoted value).
  Resolve any such nested reference before storing the new token: the final
  restoration pass walks preserved tokens once in index order, so a
  later-added token that still embeds a reference to an earlier index would
  otherwise never get resolved and would leak into the output as a raw
  ___YUICSSMIN_PRESERVED_TOKEN_N___ placeholder.
An empty '@layer name {}' declares the layer and fixes its position in the
cascade order, so removing it changes rendering; the empty-rule removal had
no at-rule awareness at all. Excluding '@' from the removal regex's char
class is not sufficient on its own: replaceAll takes the leftmost match at
any position, so it just resumes matching right after the '@', corrupting
"@layer utilities {}" into a stray "@" glued onto the next selector. The
fix matches three explicit cases instead: an '@layer' prelude is kept
verbatim, any other at-rule prelude (e.g. '@media', which does nothing when
empty) is matched and deleted as one unit so its '@' is never left
stranded, and a plain empty rule is deleted as before.

Also extends the at-rule name lowercasing list with the modern at-rules
this file already special-cases elsewhere: supports, container, layer,
property, scope, starting-style.

Adds a regression test asserting compressed output never contains the
'___YUICSSMIN' placeholder prefix, run across inputs that exercise several
preservation passes at once (string, data URI, calc(), preserved comment,
ordinary comment, custom property, @Property block), to guard against a
class of defect where an internal placeholder leaks into shipped output.
zeros.css.min expects "transition-delay:0" from "0.0ms". CSS Values and Units
Level 3 allows omitting the unit only for a zero <length>; <time> has no such
exemption, so that golden encodes invalid CSS. Current behaviour already keeps
the unit, which is correct, so the golden stays quarantined with a written
reason rather than being matched or edited.

Adds tests locking in that zero <time> keeps its unit while zero <length>
still drops it.
…hand methods

Rhino's AST keeps the optionality, but the generator had no case for it and fell
through to toSource(), which does not emit the operator either. Turning a?.b into
a.b changes a safe undefined into a TypeError.

An optional catch binding was emitted as 'catch()', which is a syntax error.

ES6 shorthand methods in object literals were expanded to 'm:function(){}',
losing the shorthand form.
Rhino marks every link of an optional chain with the same QUESTION_DOT node
type (and FunctionCall.isOptionalCall() over-reports the same way), not just
the one link that actually carries "?." in the source. The prior fix treated
QUESTION_DOT as "emit ?." unconditionally, which widened mixed chains such as
"a?.b.c" into "a?.b?.c": on a null "a", the original throws a TypeError while
the widened version silently evaluates to undefined. That is its own class of
silent behaviour change, symmetrical to the bug this feature was meant to fix.

MungedCodeGenerator now takes the original source text and, for each
QUESTION_DOT-typed property/element access and for each call, reads the actual
text between the target and the next token to decide whether that specific
link has its own "?." or is a plain "."/"["/"(" continuation of a chain that
started optional earlier. Comments are stripped from the compared text first
so a comment's literal characters can never be mistaken for the operator, and
out-of-range positions fall back to the conservative, non-optional form.

JavaScriptCompressor now keeps the source string it already reads and passes
it into MungedCodeGenerator; the old 2-arg constructor remains as a
backward-compatible overload for existing callers that build an AST without
an associated source string.
…ards

The pipeline rendered the AST to a string and then ran ten blind regexes over it,
while only single and double quoted strings were protected. Template literal and
regex literal contents were therefore rewritten, and the line breaker sliced the
final string at fixed offsets, cutting through identifiers.

The generator now emits only the separators the syntax requires, so the
post-processing stage is gone and with it this entire class of corruption.
Line breaking is now driven by offsets the generator records between
statements, so a break can never land inside a token either.
…erand merges

Four pre-existing MungedCodeGenerator defects, all exposed by Task 12's
removal of the "emit loosely, strip with regex" pipeline:

- yield* silently lost its star (Yield reports Token.YIELD_STAR, not
  Token.YIELD, for the delegating form; the switch had no case for it and
  fell back to Rhino's own toSource(), which drops the star).
- Any labeled statement crashed the compressor with a ClassCastException.
  LabeledStatement.getType() reports Token.EXPR_VOID, not Token.LABEL, so
  it was always miscast as a plain ExpressionStatement. The now-provably
  dead `case Token.LABEL:` (Label, not LabeledStatement, is the only node
  that ever reports that type, and it's never dispatched through
  visitNode()) was removed rather than left as misleading dead code.
- "a + +b", "a - -b", and "start + +new Date()" rendered as "a++b",
  "a--b", "start++new Date()" - all SyntaxErrors - because the operator's
  trailing +/- ran directly into the operand's leading +/-.
- "x / /re/.test(...)" rendered as "x//re/.test(...)", turning the rest of
  the statement into a line comment. This passes `node --check` (the
  output still parses) while silently changing what the code does.

The last two share one fix: after an infix operator or a unary operator
renders, check whether the operand's first rendered character would
combine with the operator's last character into a different token
(+/+, -/-, //, /*), and insert a single separating space only then.

Also strengthened ModernJsTest's lineBreakNeverSplitsAnIdentifier: its
prior `contains("beta")` assertion did not discriminate against a
regression to the project's old fixed-column line slicer, since the
first "beta" occurrence happens to land exactly on a 20-column boundary
for this input while the second gets split - now counts whole-token
`\bbeta\b` matches and requires both to survive intact.

267 baseline tests + 7 new -> 274 tests, 0 failures, 2 skipped. Golden
fixtures (float.js byte-identical) and JsOutputSyntaxTest (jQuery
included) unaffected; jQuery 1.6.4's compressed output is byte-identical
before/after, since it doesn't exercise any of the four patterns.
team-lead's systematic adjacency sweep found four more operator/operand
merge cases beyond the two ("a + +b", "a - -b") in the original brief:

  - -a     -> --a     (double unary minus silently becomes pre-decrement,
                        mutating the operand instead of leaving it alone)
  + +c     -> ++c     (same shape for unary plus / pre-increment)
  a + ++b  -> a+++b    (reparses as "(a++) + b" - increments the wrong
                        variable and changes the result)
  a - --b  -> a---b    (same shape for subtraction / pre-decrement)

Verified against 86326d4 (the existing fix) that all four already produce
correct output: the fix checks the operand's actual rendered first
character rather than special-casing specific node-type pairs, so it
already generalizes to nested unary and prefix-increment/decrement
operands. Confirmed two ways:

  - String diff against a rebuild of the pre-86326d4 generator (from
    11fc36b) shows the four cases regress to exactly the broken forms
    above under the old code, and match the fixed forms under current
    code.
  - Ran both the original (uncompressed) source and the compressor's
    output for all four cases (plus the two matching subtraction/postfix
    variants) through node and diffed the resulting values/mutations -
    byte-for-byte identical in every case.

Added five regression tests with exact (assertEquals) assertions per
team-lead's request, including one that asserts "a++ + b" and "a + ++b"
- which collide to the identical string "a+++b" under the old,
separator-free code - now render differently.

No production code change; MungedCodeGenerator.java is unchanged.

279 tests (274 + 5 new), 0 failures, 2 skipped.
…colons

Fix round 1 on defects (C) and (B), from a systematic adjacency sweep and
a re-check of the (B) fix's sibling logic.

CRITICAL: a bare "<" immediately before an operand rendering "!--" (e.g.
"!" applied to a prefix "--x") formed "<!--", the Annex B
SingleLineHTMLOpenComment:

  a < !--b   ->   a<!--b

Since minified output is a single line, that "comment" swallowed the rest
of the entire FILE, not just the rest of the statement - worse than
defect (D), and like it, `node --check` reported the corrupted output as
valid (it parses; it just does nothing from that point on). insertSeparat
orIfMerging previously only ever looked one character ahead, which cannot
distinguish "<" from "<=" or "<<" (both safe - once consumed as their own
token, the next token scan starts past where "<!--" could ever form), so
this needed a dedicated 3-character lookahead scoped to the bare "<"
operator specifically.

IMPORTANT: needsSemicolon still excluded Token.LABEL - the same
misconception the (B) fix corrected in visitNode's switch.
LabeledStatement.getType() is always Token.EXPR_VOID, so that exclusion
never fired, and every labeled statement got an unconditional trailing
";", including ones that don't need one:

  outer:for(var i=0;i<3;i++){f();}   ->   outer:for(...){{f();}};
  outer:{ g(); }                     ->   outer:{g();};

needsSemicolon now recurses into the labeled statement's wrapped
statement instead of special-casing labels directly, so "outer: x();"
(whose wrapped statement is an ExpressionStatement) still keeps its ";",
while a labeled for-loop or block no longer gains a needless one.

Verified as both unaffected (no regression, no needless bytes): "a << !--b"
(-> "a<<!--b"), "a <= b", "a < b", and "a-- > b" (-> "a-->b" - "-->" is
only special at the start of a line, which the generator never produces
it in, so a separator there would only cost bytes).

Tightened the existing yield*/plain-yield/labeled-statement tests
(originally contains()-based) to exact assertEquals, per review feedback
that a contains-only test is why the semicolon gap went undetected.

287 tests (279 + 8 new), 0 failures, 2 skipped. float.js golden
byte-identical, JsOutputSyntaxTest 4/4 (jQuery included), jQuery 1.6.4
compressed output unchanged (0 byte delta).
…building scopes

ScopeBuilder's generic child recursion walked Rhino's low-level
Node.getFirstChild()/getNext() chain, which is only populated for
list-style containers (Block, AstRoot, ...). AST nodes that keep their
children in typed fields instead - FunctionCall arguments, ObjectLiteral
property values, ArrayLiteral elements, IfStatement/WhileLoop/ForLoop
bodies, InfixExpression operands, and so on - reported zero children
through that chain, so the fallback silently skipped them entirely.

A function expression sitting in one of those positions never got a
scope, so its parameters were never munged:

  p.then(function(longParamName){...})     // call argument
  var o = { m: function(longParamName){...} }  // object property value
  var arr = [ function(longParamName){...} ]   // array element

The same gap also meant a `var` declared inside an if/while/for/switch
body was never discovered at all, since ScopeBuilder could not descend
past the containing IfStatement/WhileLoop/... node either:

  function outer(x) { if (x) { var innerVariableName = 1; } ... }

jQuery, in particular, is overwhelmingly fn(function(){...}) and
{key: function(){...}}, which is why it compressed to 137798 bytes
against a 101992-byte golden - 26% too large.

Replaced the broken low-level-chain fallback with one built on
AstNode.visit(NodeVisitor), which is what Rhino itself uses to enumerate
a node's children correctly regardless of how they are stored, so it
does not require hand-enumerating every AST node type the way the
low-level chain (or a from-scratch reimplementation of the per-type
traversal already in MungedCodeGenerator) would. Since ScopeBuilder
threads currentScope/braceNesting as method parameters rather than
mutable instance state, the replacement is scoped to enumerate exactly
the direct children of one node at a time (return true once for self,
false afterward), delegating each child straight back into visitNode(),
which continues the recursive descent with the correct scope.

jQuery 1.6.4 now compresses to 106970 bytes (was 137798) against the
101992-byte golden - the 26% gap is under 5%. The residual gap is not a
munging defect: 171 bytes are a second preserved "/*!" banner comment
(a bundled Sizzle engine license at line 3770) that this generation
preserves and the golden's generation did not, and the rest is different
(but equally valid) short-name choices from the free-symbol pool between
compressor generations. Documented in JsGoldenFileTest, which keeps
jquery-1.6.4.js quarantined for that reason.

Of the three previously-quarantined JS goldens:
- promise-catch-finally-issue203.js now matches its golden exactly (the
  only prior difference was munging: function(res) vs function(a)) and
  is removed from KNOWN_FAILURES.
- issue86.js still does not match, but for an unrelated, pre-existing
  reason with nothing to do with scope or munging: the compressor emits
  ".0.toString()" (a leading-dot numeric literal, valid on its own) where
  the golden has "(0).toString()" (parenthesized) - a MungedCodeGenerator
  numeric-literal formatting difference, out of this task's scope.
  KNOWN_FAILURES' comment is updated to describe this precisely.
- jquery-1.6.4.js stays quarantined per above.

Also closes a class of bug from two Criticals earlier this release
(division-before-regex forming "//", and "<" before "!--" forming
"<!--") for good: added a string-level comment-injection guard to
JsOutputSyntaxTest, run over the same fixtures the syntax check uses.
"node --check" only proves output parses, never that it means the same
thing - node's non-strict scripts still honor the legacy Annex B.1.3
HTML-comment-in-script grammar, so "<!--" anywhere, or "-->" at the
start of a line, silently opens a comment instead of raising a syntax
error, and so does a stray "//" or "/*" the generator never intended as
one. The guard is a small string scanner (not a full lexer) that skips
string/template/regex literal content and the interior of a preserved
"/*!" banner comment - a blind substring search was tried first and
immediately false-positived on jQuery's own legitimate output (regex
literals like /^\/\// and strings like "//" both occur in its compressed
form), so context-awareness is required, not optional. Covered by direct
unit tests of the scanner itself (catches each of the four sequences;
does not flag the legitimate look-alikes above) in addition to running
over all four JS fixtures.

311 tests (287 + 24 new), 0 failures, 2 skipped. JsOutputSyntaxTest
19/19 (4 syntax + 4 injection-guard-over-fixtures + 11 direct scanner
unit tests). float.js golden byte-identical. No .min, fixture, or CSS
file changed.
…guard

Two shapes let a "/" that is actually division get misclassified by
CommentInjectionScanner as opening a regex literal. Once misclassified,
skipRegex() treats everything up to the next unescaped "/" as inert
regex body without scanning it at all, so any of the four dangerous
sequences hiding in that span was a genuine false negative - exactly
what this guard exists to catch, going undetected by the guard itself.

1. "}" was in REGEX_PRECURSORS, but it is reachable immediately before a
   genuine division, not just before a block statement's regex:
   "var f = {} / a;" compresses to "var f={}/a;". Removed "}" from the
   set, so the "/" after it is treated as division and its span is
   scanned like ordinary code. The competing, also-reachable case - a
   real regex literal right after a block statement, e.g.
   "if(x){} /re/.test(y);" -> "if(x){{}}/re/.test(y);" - stays safe:
   scanned as ordinary code, "re/.test(y);" contains none of the four
   sequences, so no false positive.

2. Found by auditing the rest of REGEX_PRECURSORS for the same shape:
   postfix "++"/"--" was folded into a plain "+"/"-" token by the
   tokenizer's generic one-char fallback, which IS a regex precursor -
   correct for a binary/unary "+"/"-" awaiting an operand, wrong for a
   postfix operator that has already completed a value. "var b = a++ /
   2;" compresses to "var b=a++/2;", also reachable. "++"/"--" now get
   their own two-character token, deliberately kept out of
   REGEX_PRECURSORS.

Both closed with a reachable counterexample turned into a regression
test first (confirmed failing against the prior code), then fixed, then
reconfirmed passing - plus a paired "still allows the legitimate case"
test for each, so neither fix quietly narrows the guard past what the
corpus needs.

Corrected two comments that overstated what the guard actually
guarantees: the class-level test comment no longer claims the sequence
class is "closed for good" outright, and CommentInjectionScanner's
javadoc no longer claims a misclassification "can only cause a missed
regex boundary, not a missed injection" - that was false, as this fix
demonstrates. It now states the real asymmetry (misreading a real regex
precursor as division is a safe false positive; misreading division as
a regex precursor is the dangerous direction) and is explicit that
REGEX_PRECURSORS is a curated allowlist against known-reachable cases,
not a derivation from the grammar - i.e. not a proof no third case
exists.

315 tests (311 + 4 new), 0 failures, 2 skipped. JsOutputSyntaxTest
23/23. compressedOutputHasNoCommentInjection still passes clean on all
four real fixtures, jQuery included. ScopeBuilder.java untouched.
…pted NUL bytes

Continues the R17 line of fixes to CommentInjectionScanner's
REGEX_PRECURSORS - each entry removed here follows the same shape:
a token that is reachable immediately before genuine division in real
compressor output, not just before its "textbook" regex-precursor use.

1. Removed "of" from REGEX_PRECURSORS. "of" is a contextual keyword,
   not a reserved word - an ordinary identifier everywhere outside a
   for-of head, and JavaScriptCompressor's own munged-name pool already
   treats it that way (twos.remove("of") is filed separately as "ES6+
   two-letter keywords" from the genuinely reserved words above it).
   Confirmed reachable: "var of = 4; var x = of / 2;" compresses to
   "var of=4;var x=of/2;". Through the scanner, "x=of/y<!--INJECT-->z/w;"
   reported zero violations before this fix - the same failure shape as
   "}" and postfix "++". Added the same RED (counterexample) -> GREEN
   (fixed) -> paired-allow (competing case, "for(x of/re/g.exec(s)){}",
   does not become a false positive) test triad used for the earlier two.

2. Pinned the accepted false-positive tradeoff from removing "}" with
   an explicit, clearly-labelled regression test:
   "if(x){{y=1;}}/foo\/\//.test(z);" - a genuine regex literal
   containing escaped slashes, sitting right after a block statement -
   is scanned as ordinary code once "}" stops being treated as a regex
   precursor, and its own escaped "\/" pairs read as a live "//" once
   they are no longer skipped whole as regex content. This is accepted
   as the deliberate cost (a false positive costs one investigation; a
   false negative in the other direction ships a Critical, and the
   asymmetry is why "}" was removed from REGEX_PRECURSORS in the first
   place) rather than special-cased away, which would add real scanner
   complexity for a test-only guard. The test exists specifically so a
   future contributor who sees this fire does not "fix" it by putting
   "}" back and silently reopening the false negative.

3. Fixed three literal NUL bytes (0x00) that had ended up in the
   `lastToken = " value";` sentinels in skipQuoted/skipTemplate/
   skipRegex, present since 39de1e9. Not cosmetic: it made `grep`, and
   other locale-aware tools, treat the whole file as binary and return
   nothing, which cost real time during review. Replaced with real
   space characters (byte-level, to be certain); `grep -c "lastToken"`
   on the file now returns a count instead of nothing, and `file`
   reports it as text.

4. Clarified the "++"/"--" tokenizer comment to also cover prefix use,
   correcting a wrong assumption along the way: prefix "++"/"--"
   immediately before a regex literal is NOT rejected as a syntax error
   by node or by Rhino, and is real, reachable compressor output
   ("var a=1; ++/x/.test(a);" compresses to itself unchanged) - prefix
   "++" only requires a syntactic LeftHandSideExpression operand, which
   a regex-literal-then-call-expression satisfies; whether the result is
   actually assignable is a separate, later check this compressor
   reproduces rather than performs, same as node's own checker. The
   comment now gives the correct reason the shared "++"/"--" token is
   safe for both: excluding it from REGEX_PRECURSORS puts the following
   "/" on the safe side of the documented misclassification asymmetry
   either way - scanning a real regex as ordinary code costs nothing
   when, as here, it holds none of the four dangerous sequences - so the
   token not distinguishing prefix from postfix does not matter.
   Added a regression test for this reachable prefix case too.

Class-level CommentInjectionScanner javadoc updated: three reachable
cases found so far ("}", postfix "++"/"--", "of"), not two; references
the pinned false-positive test; states plainly this is a curated
allowlist against cases found reachable, not a grammar-derived proof
of completeness.

320 tests (315 + 5 new), 0 failures, 2 skipped. JsOutputSyntaxTest
28/28; compressedOutputHasNoCommentInjection confirmed individually
green for all four real fixtures, jQuery included. ScopeBuilder.java
untouched - diff is JsOutputSyntaxTest.java only.
ScopeBuilder never called ScriptOrFnScope.preventMunging(), so a local
readable by a direct eval() call, or shadowable by a with statement,
could still be renamed - breaking eval("name") lookups and with's
dynamic property resolution. This regressed with the prior scope-tree
fix, which started giving scopes (and munging) to function expressions
in call arguments that eval could previously reach only by accident.

ScopeBuilder now marks the current scope and every enclosing scope as
unsafe to munge when it sees a bare reference to the identifier "eval"
(called or not; a property access like obj.eval is unaffected, since
that's always an indirect eval and can't see locals anyway) or a with
statement.

Also fixes ScriptOrFnScope.munge(), which returned before reaching its
own recursion into sub-scopes whenever the current scope was unsafe -
previously unreachable dead code, since preventMunging() had no
callers. Left as-is, protecting one scope would have silently disabled
munging for every function nested inside it, even ones with no eval or
with of their own.
- CHANGELOG.md: add the 2.4.11-SNAPSHOT entry covering the CSS and
  JavaScript correctness fixes, the ScopeBuilder compression
  improvement, and the JUnit 5 / golden-fixture / build test-tooling
  work
- README.md: fix the stale "2.4.10-SNAPSHOT" version references (the
  version line and the two example jar filenames) to match pom.xml's
  2.4.11-SNAPSHOT
- docs/ES6_MIGRATION_PLAN.md: correct the completion checklist against
  measured Rhino behaviour
  - "ES6構文をパースしてもエラーにならない" was false: Rhino cannot
    parse class, async/await, import/export, dynamic import(),
    new.target, or for-of with const, at any version
  - "for-in/for-ofループ" was partially false: for-of works with
    var/let but not const
  - "labeled文" was false when written (any labeled statement crashed
    with a ClassCastException); true only as of this release
  - checked off the two Phase 1.1 items that were actually already
    done (VERSION_ES6, CompilerEnvirons review)
Review round 1 caught three overstatements in the 2.4.11-SNAPSHOT entry:

- the CSS lowercasing bullet claimed at-rule, function, and pseudo-class
  names were all normalised; only the at-rule directive list changed in
  this release (commit a186768), the function-name and pseudo-class
  regexes are untouched. Narrowed the claim to at-rule names.
- the optional-chaining bullet named the wrong variable as the source of
  the divergence between `a?.b.c` and `a?.b?.c`. Verified in Node: both
  forms return `undefined` (no throw) when `a` itself is null, since the
  chain short-circuits at the first `?.`. The divergence is when `a` is
  non-null but `a.b` is null: `a?.b.c` throws a TypeError, the widened
  `a?.b?.c` silently swallows it. Rewrote the explanation to match.
- the comment-injection bullet implied `//` injection was less severe
  than `<!--` injection ("rest of the statement" vs "rest of the file").
  Both are single-line comment openers and minified output is a single
  line, so both swallow everything after them. Made the descriptions
  symmetric.
`MungedCodeGenerator.visitNode`'s `default:` arm calls `node.toSource()`,
which re-prints the ORIGINAL source of the whole subtree. Every identifier
inside therefore keeps its pre-munge spelling while its declaration was
munged, silently turning locals into globals, and `?.` is dropped outright.
The output parses, so neither the goldens nor `node --check` caught it:

    function f(alpha, beta) { var gamma = alpha ?? beta; return gamma; }
    -> function f(c,b){var a=alpha ?? beta;return a;}

Two changes, plus one addition beyond the reported four operators.

1. Handle the operators that reached the fallback. Verified against the
   Rhino 1.8.0 constant pool rather than assumed names:
   `NULLISH_COALESCING` (an `InfixExpression`), and `ASSIGN_LOGICAL_OR`,
   `ASSIGN_LOGICAL_AND`, `ASSIGN_NULLISH`, `ASSIGN_EXP` (all `Assignment`,
   which extends `InfixExpression`). Routing them through
   `visitInfixExpression` visits both operands, so they munge, and keeps
   them in `insertSeparatorIfMerging`'s view. `**=` was not in the original
   triage; it leaked the same way and is fixed with the rest. Parentheses
   around `??` mixed with `||`/`&&` survive because Rhino records them as
   `ParenthesizedExpression` nodes, which already have a case.

   `Token.DEBUGGER` is also handled here now. It too reached the fallback,
   where `toSource()` emitted `debugger;\n` - an embedded newline in output
   that both `addLineBreaks` and the Annex B merge-hazard reasoning assume
   is a single line - and then `needsSemicolon()` added a second `;`.

2. Make the fallback loud. `MungedCodeGenerator.STRICT_PROPERTY`
   (`yuicompressor.strict`) turns `default:` into a thrown
   `UnsupportedSyntaxException` naming the node type and class, alongside
   the existing `yuicompressor.debug` warning hook. Off by default, so
   production callers keep today's behaviour.

   This matters more than the five operator cases: Release 2 upgrades
   Rhino, which makes more syntax parse, which routes MORE node types into
   the fallback. Fixing only the known operators would leave the trap armed
   and better hidden.

Tests: `StrictNodeCoverageTest` runs every `.js` fixture plus a 25-row
modern-syntax table under strict mode (zero fallbacks), records the syntax
Rhino genuinely cannot parse as expected parse failures, and pins both
directions of the flag using array comprehensions - a node type Rhino still
parses and this generator has no case for. `ModernJsTest` gains munged-output
assertions for each operator, the `??` + `?.` end-to-end case, parenthesis
preservation, and the `debugger` newline.
`insertSeparatorIfMerging` inserts its separating space with a raw
`output.insert(mark, ' ')`, but the operand was already rendered by then, so
any safe-break offset recorded inside it (a nested function expression's
statement boundaries) was recorded at its pre-insertion position and never
adjusted. Every such offset therefore landed k characters early, k being the
number of separators inserted before it, and `addLineBreaks` cut inside the
preceding token.

At `--line-break 20`, with two nested insertions (k=2):

    var q = a + + +function(){ abcdefghijklmnop; }();
    -> var q=a+ + +function(){abcdefghijklmno
       p
       ;}();

    var q = a + + +function(){ var s = "hello"; }();
    -> var q=a+ + +function(){var a="hello
       "
       ;}();

Only the second is loud. The first still parses - as two statements naming
two different variables - which is why this survived the review rounds that
checked output with `node --check`.

Route both insert sites through a new `insertSeparator(int)` that shifts
every already-recorded offset at or after the insertion point. The list is
ascending, so the walk stops at the first offset below `mark`.

Tests: three cases at `--line-break 20` covering k=2 (identifier split and
string-literal split) and k=1, each asserting the exact output, that Rhino
re-parses it, and that every inserted newline follows a ";" or "}". The
existing `lineBreakNeverSplitsAnIdentifier` gains the same boundary check.
Parsing alone is deliberately not the only assertion: the identifier case
parses either way. Rhino is used rather than node so the check is
unconditional.
Four matchers in CssCompressor located their construct by its literal text,
with no regard for whether an at-rule or a declaration could actually begin
there. Two were reported; the audit asked for by the review found two more of
the same shape. All are fixed by one shared `startsAtBoundary` helper rather
than four separate guards.

1. `@property` (reported as I2). Located context-free, so the match preserved
   everything up to the next balanced "}" verbatim - which meant the rule
   AFTER it was emitted completely unminified:

       a { background: url(/img/@property.png) } b { color: #ff0000; margin: 0px }
       -> a{background:url(/img/@property.png) } b { color: #ff0000; margin: 0px }

2. Custom property declarations (reported as I3). The matcher accepted only
   "{" or ";" as the preceding character, so a preserved comment between the
   "{" and the declaration made the value ordinary again:

       :root{/*! v1 */--brand:#ff0000}  ->  --brand:red
       :root{/*! x */--pad:0px}         ->  --pad:0

   The second is the damaging one: calc(var(--pad) + 1px) needs the unit.

3. The at-directive lowercasing pass. Also context-free, and URL paths are
   case-sensitive on essentially every server:

       url(/img/@MEDIA.png)  ->  url(/img/@media.png)

4. The `@charset` hoisting pass. The literal text inside an unpreserved url()
   was hoisted out of the URL to the top of the stylesheet:

       a { background: url(/x/@charset "y";) }
       -> @charset "y";a{background:url(/x/)}

The second `@charset` pass is anchored at start-of-input with only whitespace
allowed before it, so it is already boundary-safe and is unchanged. The empty-
rule `@layer` matcher requires "{}" immediately after the prelude, which cannot
occur inside a URL or any other value, so it is not exposed either.

`startsAtBoundary` accepts the start of the stylesheet, one of a given set of
boundary characters, or a preserved-token placeholder. That last part is not
optional and is what I3 was missing: by the time these passes run, a leading
comment is already a placeholder. Note the placeholder keeps its delimiters -
a preserved comment reads "/*" + placeholder + "*/" and a preserved string
keeps its quotes - so the detector steps over the closing delimiter first.
Getting this wrong initially regressed `/*! keep */@media screen{...}`, which
now has its own test.

Tests: eight cases in ModernCssTest, covering each of the four defects and,
for each, the legitimate case the boundary check must not break - an at-rule
after a preserved comment, a real leading `@charset`, and `calc(1px --2px)`,
whose "--2px" must still not be read as a declaration.
…enerator methods

Five fixes in MungedCodeGenerator, plus one in ScopeBuilder that the first of
them made necessary.

1. Default and rest parameters were silently dropped. `visitParameterList`
   emitted the bare binding name and ignored everything around it:

       function f(a=1){ return a; }               -> function f(a){return a;}
       function f(...args){ return args.length; } -> function f(args){...}

   The first changes `f()` from 1 to undefined; the second turns an array of
   the trailing arguments into a single positional parameter and changes
   `f.length`. Rhino spreads a parameter's syntax across three places -
   `getParams()` (binding target only, a rest parameter appearing as a plain
   `Name`), `hasRestParameter()` (a flag on the function), and
   `getDefaultParams()` (a flat, alternating list of ORIGINAL parameter name
   and default expression) - and this read only the first.

   A destructuring pattern's default is recorded nowhere at all, so
   `function f({b}={})` and `function f({b})` produce identical parameter
   nodes. The source text is consulted to tell them apart: the plain pattern
   is reproduced exactly, and the one with a default throws rather than emit a
   parameter list quietly missing it.

   The default expression is live code, so it is visited rather than printed.
   That exposed two things Rhino's side-list storage hides:

   - The expression has no parent link back to the function, so
     `findScopeForVariable` walked straight off the top and resolved every
     name against the GLOBAL scope: `function f(alpha, beta=alpha)` emitted
     `function f(b,a=alpha)`. The missing link is restored before visiting.
   - `ScopeBuilder` never traversed default expressions either, so a name read
     there was not a recorded use and an `eval` there did not disable munging.
     It now visits them with the function's own scope. This only became
     reachable once defaults stopped being dropped.

   The single-parameter arrow shortcut drops the parentheses, which would have
   taken a `=1` with them; it is now only taken when there is no default.

2. Redundant double braces. Rhino wraps a loop, if- or do-body that declares
   anything in a `Scope`, which does NOT extend `Block`, so the
   `body instanceof Block` check missed it and wrapped an already-braced block
   in a second pair: `for(...){f();}` came out as `for(...){{f();}}`. Checking
   the node's TYPE catches `Block` and `Scope` alike, and correctly excludes
   `AstRoot`/`FunctionNode`, which are `Scope` subclasses reporting
   SCRIPT/FUNCTION. Measured on jQuery 1.6.4: 106,970 -> 104,770 bytes, `{{`
   count 1,100 -> 0, `node --check` clean, goldens unchanged.

3. Generator object methods crashed. Rhino wraps a generator method's key in a
   `GeneratorMethodDefinition` whose type is `Token.MUL`, so
   `var o = { *gen(){ yield 1; } };` reached the infix path and died with a
   `ClassCastException`. Handled in the object-literal path, computed keys
   included.

4. The getter and setter branches failed open: they emitted `get `/`set ` and
   the key, then the parameter list and body only `if (right instanceof
   FunctionNode)`, so a non-FunctionNode right-hand side produced `{get x}`,
   which is invalid. They now go to the fallback instead of emitting a
   truncated property. The three method branches are one method now, since
   they differ only in their prefix.

5. `stripComments` strips line comments before block comments, so a `//`
   comment whose own text contains `/*` cannot open a block comment that runs
   past the end of the line and swallows a genuine `?.`.

Tests: `ParameterListTest` is the round-trip table - seven forms that compress
correctly (including default-expression munging, enclosing-scope resolution,
and eval-in-a-default), three that fail loudly. `ModernJsTest` gains brace
tests in both directions, including the dangling-else binding and empty loop
bodies, plus the two generator-method cases.

The two `ModernJsTest` labelled-statement tests hard-coded `{{f();}}` in their
expected strings while actually testing labels and semicolons; their expected
values are updated and their intent is unchanged. Three `JsOutputSyntaxTest`
scanner comments cited `if(x){{}}` as real generator output; the comments stay
correct in substance but their worked examples are now written as the output
the generator actually produces.
… filter

Two halves of the same problem: the suite could not tell "the output parses"
from "the output means the same thing", and five golden pairs were excluded
from every test by a filename convention documented nowhere.

DifferentialExecutionTest runs each of 25 small, self-contained, deterministic
scripts under node twice - once as source, once compressed - and compares
stdout plus exit status. Seeded from the defects this release found, all of
which produced output that parses: the "??" un-munging, the deleted "?.",
optional-chain widening, the "a + +b" operator merges, dropped default and
rest parameters, "yield*", labelled break, generator methods, eval/with
protection, and the --line-break token splits at column 20.

It immediately found a defect nobody had reported: a shorthand property is one
identifier serving as BOTH the property key and the binding, so munging it
renamed the key with it.

    function f({b}){ return b; }   called with {b:7}   ->  undefined
    function f(){ var longLocalName = 7; return { longLocalName }; }
                                   ->  property renamed to the munged name

Both compress to output that parses cleanly and simply returns the wrong
thing, in an object literal and in a destructuring pattern (including a
destructured parameter) alike. Fixed by expanding to "b:a" - key kept, binding
munged - and only when the two actually differ, so an unmunged shorthand stays
shorthand and costs nothing. jQuery output is byte-identical at 104,770.

The "_" filter is removed from all three test classes that carried it
(JsGoldenFileTest, JsOutputSyntaxTest, CssGoldenFileTest - the last had no
matching fixtures, so it only removed the convention). The five JS pairs it
hid are now listed in KNOWN_FAILURES with measured reasons, re-checked after
this wave's fixes rather than copied from the report:

- _munge.js: the "a:nomunge" hint is ignored AND emitted as a live string
  statement. Its golden also looks wrong - it munges "var w = window" to "a"
  while preserving a parameter also named "a", so its "a.alert(...)" calls
  alert on the parameter, not on window. Ours keeps those distinct.
- _string_combo.js: string-literal merging absent.
- _string_combo2.js, _string_combo3.js: the ";}" difference only.
- _syntax_error.js: quote-character normalisation absent.

Correcting the report on one point: it predicted the double-brace fix would
make _string_combo3.js match. It does not. The "{{" is gone, but ";}" was a
second, independent cause, so the fixture stays quarantined.

Per ruling R25 the underlying optimisations (";}" stripping, string merging,
quote choice, nomunge hints) are Release 2 work and are recorded, not
implemented.

The jquery-1.6.4.js quarantine reason is rewritten from figures measured after
this wave (see the next commit's honesty pass for the rest): both stated causes
in the old text were false. The golden contains BOTH "/*!" banners, 537 bytes,
byte-identical to ours - accounting for zero of the gap, not 171 bytes - and
the names said to differ "never in length" differ by 1,280 characters over an
identical 18,037 identifier tokens, so our short-name allocation is genuinely
worse rather than merely different.

JsOutputSyntaxTest now covers 9 JS fixtures rather than 4; all five newly
included ones pass both node --check and the comment-injection scan.
Every number below was re-measured after this wave's fixes rather than carried
over.

CHANGELOG:
- the optional-chaining bullet read as an unconditional guarantee. It was true
  for its own example but not for chains inside a node type that reached the
  toSource() fallback, where "?." was deleted outright. Qualified, with a
  pointer to the new entry that closes it
- "custom property values preserved verbatim" was false when a preserved
  comment sat between the declaration and its "{" or ";". Qualified and the
  fix recorded
- "node --check against every compressed fixture" ran against 4 of 9 JS
  fixtures. It now genuinely runs against all 10
- "66 golden fixture pairs (62 CSS, 4 JS)" - there are 72 (62 CSS, 10 JS), of
  which 64 execute and 8 are quarantined, plus 3 disabled by the .FAIL
  convention
- "328 tests" is now 457 (2 skipped; 422 execute without node on PATH)
- added entries for everything this wave fixes, plus a "Documented" section for
  the three options that are accepted and ignored

README:
- --line-break 0. The report said this was a no-op; that is only true of the
  JavaScript path, which guards on "> 0". CssCompressor guards on ">= 0", so
  --line-break 0 really does give a line break after each CSS rule, exactly as
  documented. Verified both. Corrected to say so rather than deleting a claim
  that turned out to be half right
- --preserve-semi, --disable-optimizations and -v/--verbose marked NOT
  CURRENTLY IMPLEMENTED, matching new @PARAM javadoc on both compress()
  overloads. Documentation only, per ruling R25
- the "nomunge" hints section claimed "the hint itself disappears from the
  compressed file". It does not: the symbols are munged anyway AND the hint is
  emitted as a live string statement (this is what _munge.js records)

ES6SupportTest.testParseDefaultParams and testParseRestParams called
parseSource and asserted assertNotNull(ast) - they never invoked the
compressor, but sat among 44 tests that do, so the class read as evidence of
support for syntax the generator silently broke. Now that it reconstructs both,
they are real round-trip tests named for what they check.

Correcting the report on the three "orphan" goldens (M6). Its premise is wrong
in two ways. `hsla-issue81.css`, `issue172.css` and `rgb-issue81.css` DO have
sources: the repository's .FAIL convention disables a fixture by renaming its
SOURCE, leaving the .min golden under its ordinary name, which is exactly why
they look orphaned. The convention is documented in
src/test/resources/README.md and docs/TESTING.md, and suite.sh pairs the two.
Deleting the .min files would have destroyed the record of the expected output
for three known bugs.

What suite.sh actually asks for is the opposite, and applies to a fourth file:
"Test passed, please remove the '.FAIL' from the filename". issue71.js.FAIL now
matches its golden byte for byte, so its suffix is removed and it becomes a
live fixture (JsGoldenFileTest 2 -> 3 executed). The three CSS ones still fail
and stay disabled, with their actual current output recorded in docs/TESTING.md
so the next reader does not have to re-derive it. Both README files now say not
to delete a .min whose source is present under a .FAIL name.
Follow-up to the Phase 3 boundary work, acting on the reviewer's instruction
not to widen the predicate by example. Enumerating the preservation passes
rather than reasoning from reproductions turned up a false positive I had
introduced.

Three placeholder textual forms exist by the time the at-rule and declaration
scans run, and only one can legitimately precede either construct:

- "/*" + placeholder + "*/" - a preserved "/*!" banner, the Mac/IE5 backslash
  hack, or the IE7 ">/**/" hack. REAL: a banner can sit between "{" and a
  declaration. This is the I3 case.
- a quoted placeholder - a preserved string literal. NOT a boundary. A string
  abutting a declaration or at-rule ("a{content:\"x\"--y:1}") is not valid CSS,
  and a string in a value is followed by ";" or "}", which is already a
  boundary.
- a bare placeholder - the "\9" hack, or the inside of a preserved "url(...)".
  NOT a boundary. "\9" ends a declaration value so ";" or "}" follows it, and a
  bare placeholder inside "url(...)" is followed by ")".

The detector accepted the quoted form too, which I had added for consistency
rather than because anything required it. Measured consequence:

    a{background:url(/x/"y"@property.png)}b{color:#ff0000;margin:0px}
    -> a{background:url(/x/"y"@property.png)}b{color:#ff0000;margin:0px}

Rule "b" left entirely unminified - the I2 symptom, reintroduced through a
different door by the fix for it. Requiring BOTH comment delimiters closes it.

Also recorded, because it is the reason the predicate must not be widened
further: an ordinary (non-preserved) comment never reaches this test at all.
The "kill the comment" pass deletes it whole, "/*" and "*/" included, so "{"
ends up directly adjacent to what follows. That is why routine CSS such as
":root{/* note */--pad:0px}" was never affected by I3, and it now has a test so
it cannot start being affected.

Tests: four cases in ModernCssTest - the two rejected forms, the ordinary
comment, and the IE7 empty-comment hack as a guard on the narrowed detector.
461 tests, 0 failures.
The previous fix covered `{b}` and stopped one character short of `{b = 1}`.
Rhino reports `isShorthand() == false` for shorthand-with-default, so the
expansion never ran, and in that path the SAME Name object is both
`prop.getLeft()` and the `Assignment`'s left - so `visitName`'s property-key
guard fired for the BINDING too, emitting it un-munged while every reference to
it in the body was munged normally. A declaration and its uses under different
names.

Measured before, in all three positions:

    function f(o) { var someKey; ({ someKey = 5 } = o); return someKey; }
      -> function f(b){var a;({someKey:someKey=5}=b);return a;}
      source "5 9"  ->  compressed "undefined undefined"   SILENT, node --check clean

    function f({ someKey = 5 }) { return someKey; }
      -> function f({someKey:someKey=5}){return a;}         ReferenceError

    function f(o) { var { someKey = 5 } = o; return someKey; }
      -> function f(b){var {someKey:someKey=5}=b;return a;}  ReferenceError

After, all three run "5 9" like their sources:

    ({someKey:a=5}=b)          function f({someKey:a=5})     var {someKey:a=5}=b

The discriminator is object identity, verified against Rhino 1.8.0 rather than
assumed: for `{b = 1}` the Assignment's left IS `prop.getLeft()`, while
`{k: b = 1}` has an identical node shape with two DISTINCT Name objects,
because there the key and the binding really are different identifiers. That
distinction is what lets one branch handle the shorthand form without touching
the non-shorthand one.

Fixed in `visitObjectLiteral`, which all three positions route through - the
destructuring pattern is an ObjectLiteral in each - so this is one change to
the shape rather than three to the positions. When the binding is not munged
the original `{b=1}` form is kept, so nothing is expanded for nothing.

Also handles a shorthand property whose left is not a Name: previously it fell
through every branch and emitted nothing at all. Not known to be reachable, but
silent truncation is the wrong direction.

Tests: three exact-output cases per position, plus the two neighbouring forms
that were already correct (`{k: b = 5}`, `[b = 5]`) so a future change to the
discriminator cannot quietly break them, plus the unmunged case. Five new
differential cases run all three broken forms and both controls under node.
472 tests, 0 failures.
…acy forms

Four fixes, all in the same class as the round's Critical: constructs emitted
wrongly rather than caught by the fallback.

1. A trailing array elision lost its slot. Commas in an array literal are
   separators, so a trailing one is not an element - "[a,b,]" and "[a,b]" are
   both length 2 - which means a trailing hole needs an extra comma of its own.
   The separator-only loop never emitted it:

       [, , alpha, , ]   source   [null,null,7,null]  length 4
                         emitted  [,,b,]              length 3

   Silent, parses, pre-existing. Rhino's element list already models this
   correctly (it counts a trailing hole, does not count a trailing separator,
   and matched JS `length` in all seven forms checked), so the list size is the
   length to reproduce. Verified in node that every form now round-trips with
   both the same contents and the same length.

2. BigInt literals had no handler. Harmless in the lenient path - a leaf with
   no identifiers inside, so toSource() round-trips it - but strict mode could
   not compress ANY file containing one, which matters because strict mode is
   what Release 2 is meant to lean on. Emits getValue(), the source text, so
   "0xffn" is not needlessly normalised to "255n" the way toSource() does.

3. "for each (var b in a)" put its keyword in the wrong place, emitting
   "for(var a each in b)" - which this compressor's own parser rejects
   ("missing ; after for-loop initializer"). Invalid output with exit 0. The
   keyword goes before the parenthesis. Its own output now re-compresses
   cleanly.

4. "catch (e if e instanceof TypeError)" silently dropped its guard, widening
   the catch to every exception. Rhino exposes it via getCatchCondition().

Both legacy forms are Mozilla-only and unsupported everywhere, which is why
they were left; but they are emitted wrongly rather than sent to the fallback,
so the strict tripwire cannot see them - that is what makes them worth the six
lines.

On the fallback surface: sweeping the constructs Rhino 1.8.0 accepts found SIX
unhandled node types, not the four reported - DOTDOT (a..b) and DOT-as-
XmlMemberGet (a.@b) were missing from the list. With BIGINT now handled the
remaining six are ARRAYCOMP, GENEXPR, XML, REF_NAME, DOTDOT and DOT, all
Rhino/E4X legacy no browser supports. They stay throwing under strict mode and
are now enumerated in the STRICT_PROPERTY javadoc and pinned by a test, so a
seventh appearing is a real change rather than a gap in the probe.

Tests: 8 exact-output cases for the elision rule in both directions, BigInt
form preservation, and the two legacy forms; 6 strict-mode cases pinning the
unhandled list; 2 new strict-mode BigInt rows; 4 differential cases covering
array .length and BigInt arithmetic. 491 tests, 0 failures. jQuery output
unchanged at 104,770 bytes.
…onest

Two rulings from the re-review, neither of which changes what is compressed.

R28 - do not contort differential testing into a compression assertion.
Confirmed the mutation result rather than taking it on trust: reverting the D1
brace change leaves DifferentialExecutionTest 34/34 green. That is inherent -
"{{f();}}" and "{f();}" behave identically, so a class that compares source
behaviour against compressed behaviour can never detect a missed optimisation,
and a compressor that does nothing at all trivially agrees with itself.

So the class is left alone and the claims around it are corrected:

- The class docblock now states what it cannot detect by construction
  (under-compression, a dead munger, missed optimisations) and names the other
  half of the net that does: the golden fixtures pin exact bytes, and
  ParameterListTest/ModernJsTest pin exact output. Neither half is "the safety
  net" on its own.
- Two cases carried a comment calling them guards for the D1 change. They are
  not, and none could be. The comment now says so, names what actually pins D1
  (eight ModernJsTest exact-output tests, measured by reverting it), and keeps
  the cases under an honest description - loop and label semantics are still
  worth running.

R29 - a missing tool may skip; a broken tool must fail. Both probes were
`catch (Exception e) { return false; }`, so a node that was present but broken,
sandboxed or hanging disabled the entire net silently and still reported BUILD
SUCCESS. Same shape as the "_"-prefix filter this release removed.

New NodeRuntime helper, shared by both classes. Exactly one outcome skips -
node not on PATH. Present-but-non-zero-exit, wrong output, or not finishing a
trivial script within a bounded 60s all throw. Measured:

    broken node (exit 126)  ->  34 failures, "node is on PATH but failed a
                                trivial script ... Refusing to skip"
    hanging node            ->  34 failures in 5.1s at a 5s timeout, rather
                                than hanging the build

The probe caches its failure as well as its success; without that a hanging
node re-probed once per case and spent 34 x the timeout before failing (170s
measured) instead of 5.1s.

Skips are now legible. @EnabledIf disabled whole methods, so 35 real executions
hid behind 3 skip lines - "Skipped: 5" for a build that had actually stopped
checking anything with node. A per-case assumption makes the count the real
count:

    without node, before:  429 run,  5 skipped
    without node, after:   491 run, 46 skipped

Process handling is bounded and cannot deadlock: both streams go to files
rather than pipes, so a script that outwrites the pipe buffer cannot hang, and
every wait has a deadline.

One deviation from the brief, deliberate. It asked for redirectErrorStream on
the differential runner; I redirect stdout and stderr to SEPARATE files
instead. That fixes the hang just as well, and merging would have broken the
comparison: a stack trace carries file names and line numbers that legitimately
differ between the source run and the compressed run, so every throwing script
would report a spurious difference. Exit status is still compared, so a script
that throws where its source did not is still caught. "node --check", which has
no such comparison, does merge.

491 tests, 0 failures, 2 skipped with node; 46 skipped without.
Documentation, measured after the round's three fix phases.

I-6. The CHANGELOG listed `@property` among at-rule names "now normalised to
lowercase". It is not: the whole block is replaced by a preserved token before
the lowercasing pass runs, so `@PROPERTY --c {...}` passes through unchanged.
Verified, along with the other five names in the claim, which do lowercase.
Whole-block preservation is deliberate, so the claim is corrected rather than
the behaviour.

I-5. Test counts were stale. Now measured after this round: 492 tests, 3
skipped with node; 46 skipped without it.

Minors taken:

- M-1. The CSS boundary javadoc reasoned only about declarations. Records the
  one accepted cost of narrowing to comment placeholders: an at-rule directly
  after a preserved `@property` block is not recognised, so
  `@property --x{...}@media{...}` keeps `@MEDIA`. At-rule names are ASCII
  case-insensitive, so this is a missed optimisation, and the alternative is
  what reopened the corruption it fixed.
- M-2. Phase 6f converted two parse-only tests and left four. All four are now
  round-trip tests, and the two `assertNotNull(result)` tests next to them
  assert their actual output - one of which pins an array-destructuring hole,
  which the elision fix in this round touches.
- M-3. `assertEveryLineBreakIsAtAStatementBoundary` asserted nothing when no
  line break was inserted: with `addLineBreaks` made a no-op it stayed green,
  and only the exact-output tests caught it. It now requires a break to exist
  before checking where breaks landed.
- M-4. A size and shape pin for jquery-1.6.4.js: 104,770 bytes, zero `{{`,
  1,259 `;}`. Quarantining it left the only large real-world fixture with no
  byte-level guard - reverting the brace fix, worth 2,200 bytes on this exact
  file, left the golden test 3/3 green.
- M-6. `-v/--verbose` was described as accepted and ignored. Wrong about the
  flag though right about the `compress(...)` parameter: the CLI does read it,
  for one informational line when an unsupported charset is replaced.
- M-7. `compressedOutputParses` returned early for the fixture whose SOURCE
  node rejects, reporting PASSED, so the run showed 10 node --check executions
  where 9 happened. Now an explicit skip, and the CHANGELOG says 9 of 10.
- M-9. StrictNodeCoverageTest's cases assert only "did not throw", which is
  correct for a tripwire but must not be counted as output coverage. Said so in
  the class javadoc, with what does cover the output.

Minors skipped: M-5 (the three `.css.FAIL` fixtures are excluded by the `*.css`
glob rather than a named list). The convention, the per-fixture reasons and
their current output were written into docs/TESTING.md and both READMEs last
round, which is where a reader looking at those files will be; adding a test
that asserts what a deliberately-disabled fixture currently does would pin
behaviour nobody has decided is correct. M-8 is not skipped - both halves were
fixed in this round's second commit.
Comment collection is the first pass over the stylesheet, so it has to
understand CSS structure itself. It was a bare `indexOf("/*")` loop with an
`endIndex = totallen` fallback, and that single pass produced both reported
defects. They are one defect: the scan was context-free and ran before string
and URL handling.

A. A comment-looking span inside a string or an unquoted url() was collected as
   a comment. The placeholder then sat mid-value while LOOKING exactly like a
   leading banner comment, which defeated startsAtBoundary at all four call
   sites. Measured before:

     url(/x/*!k*/@property.png)    -> following rule left entirely unminified
     url(/x/*!k*/@MEDIA.png)       -> lowercased; URL paths are case-sensitive
     url(/x/*!k*/@charset "y";...) -> encoding invented from a URL fragment,
                                      and the fragment deleted from the URL
     url(/x/*!k*/--y:0px)          -> following rule left unminified

   Narrowing the predicate a third time could not have fixed this, and the
   reviewer's framing is why: a placeholder's shape records how it was created,
   never where it sits. The boundary decision has to come from the original
   stylesheet's structure, so that is where the fix goes.

B. An unterminated "/*" replaced everything to end-of-input with a marker the
   later "kill the comment" pass could not match, because that pass looks for
   the closing delimiter. Truncated stylesheet, following rules gone, internal
   scaffolding emitted into shippable CSS, exit 0:

     a{content:"/*"}b{color:#ff0000;margin:0px}
       -> a{content:"/*___YUICSSMIN_PRESERVE_CANDIDATE_COMMENT_0___

     a{background:url(/img/*/thumb.png)}b{color:#ff0000}  c{content:"*/"}
       -> a{background:url(/img"}

Both fixed at the root by scanning structurally: strings and url() tokens are
stepped over, so their contents can never be mistaken for a comment. Both of
B's reproductions are covered by that alone - neither "/*" was ever in comment
position.

A genuinely unterminated comment - outside any string or URL - now throws,
naming the offset and how many characters would have been discarded. Browsers
consume such a comment to end-of-input, so the stylesheet is already broken for
the author either way; reproducing that here would mean a minifier silently
discarding the rest of the file with a success exit code, which is exactly the
corruption this pass exists to stop. This is the R20 trade, applied to CSS.

Adversarial checks run against the new scanner, all correct: a comment inside a
QUOTED url; an escaped quote before "/*"; "url(" inside a string; string-looking
text inside a comment; uppercase URL(; an escaped ")" inside a url; a comment
immediately after a url; "url(" inside a comment; an identifier ending in "url"
("myurl(") not being taken for a url token; and a data URL carrying an
unterminated "/*", which is the realistic trigger for B and is now not a comment
at all rather than an error.

One residual, recorded not fixed: after an unterminated STRING, later comments
are not collected, so they are emitted rather than stripped. That is a missed
optimisation on malformed input, and strictly better than the previous
behaviour, which truncated.

The javadoc claim that "an ordinary comment never reaches this test at all: it
is deleted whole" was false for exactly case B. Corrected to say it holds for
terminated comments, to say what used to reach it, and not to restate it as a
settled invariant.

All 62 CSS goldens unchanged. 503 tests, 0 failures.
9b56de5 replaced a context-free comment scan with a context-free url() scan.
skipUrlToken treats every "url(" as a raw url-token and looks for the first
")", stepping over strings and backslash escapes but not over comments, so a
comment inside a QUOTED url() desynced it.

Per CSS Syntax Level 3 4.3.4, that raw treatment is only correct for the
unquoted form. When "url(" is followed - after any amount of whitespace - by a
quote, the tokenizer emits an ordinary <function-token>, and its contents are
ordinary tokens: strings, comments, and the closing ")". url("a.png" /* n */)
is valid CSS that every browser accepts. So the split belongs in
startsUrlToken, which now declines the quoted form and lets the main loop scan
it with the normal rules.

Three failures, one cause. Measured before, all exit 0 unless noted:

  .hero{background-image:url("a.png" /* legacy: url(b.png) /* keep */)}
  .nav{color:#ff0000} .footer{margin:0px}
    -> .hero{background-image:url("a.png" /* legacy:url(b.png))}.nav{...}
       An unterminated "/*" in shippable CSS. Browsers consume such a comment
       to end-of-input, so .nav and .footer are gone. This is the blocker: it
       never reaches the loud path, it just writes plausible-looking bytes over
       a good artifact.

  a{background:url("x" /* ) " */)}b{content:"/*"}
    -> exit 1, "unterminated CSS comment ... at offset 43", pointing inside the
       string in b{}. Valid CSS reaching the throw, which falsified the
       javadoc's "outside a string or URL" claim. @import url(...) the same.

  a{background:url("x.png" /* n */)}
    -> the comment shipped. The common case, needing no ")" and no nesting.

All three now match the pre-9b56de5 output, which was correct on them.

skipUrlToken is unchanged, and its javadoc now records why. It steps over a
quoted span inside an UNQUOTED url(), which looks like a spec deviation - a
quote there is a parse error, and 4.3.14 consumes bad-url remnants to the first
unescaped ")". Scanning it that way was tried and measured:

  url(data:image/svg+xml,<svg xmlns="a)b/*x*/c"/>)
    -> the token ends at the ")" inside the attribute, the collector resumes
       inside the URL, and "/*x*/" is deleted from it - defect A of 9b56de5,
       reintroduced.

Ending the token late can only suppress comment collection (a leak); ending it
early can delete document bytes (corruption). On malformed input the scanner
errs toward emitting too much, and that is the safe direction.

One new loud case: an unterminated comment inside a quoted url() now throws
instead of being emitted verbatim. That input is a stylesheet a browser reads
to end-of-input as a comment; the previous behaviour was to ship it silently.

509 tests, 0 failures, 3 skipped. All 61 CSS goldens unchanged.
Found by auditing the comment/string/URL scanner for the defect class this
release keeps repeating - a scan that matches without knowing what part of the
document it is looking at. Both of these are older than 9b56de5 and neither is
a regression from it; the first one was merely hidden by the C-3 bug and would
have become visible now that a comment inside a quoted url() is collected.

A. A comment inside a span captured by preserveToken was emitted as internal
   scaffolding. url(...data:...), calc(...) and progid:...Matrix(...) are
   captured verbatim and put back at the very end of compress(), after the loop
   that settles candidate comment markers, so anything the span swallowed is
   invisible to that loop. Measured on valid CSS, exit 0:

     a{width:calc(100% /* n */ - 10px)}
       -> a{width:calc(100% / *___YUICSSMIN_PRESERVE_CANDIDATE_COMMENT_0___ * / - 10px)}

   Inside calc() it is not only ugly. respaceCalcOperators runs after
   restoration and reads the marker's own "/*" and "*/" as division and
   multiplication, spaces them out, and the declaration a browser receives is
   broken. The other two shapes keep their comment delimiters, so a browser
   drops the marker, but it is still scaffolding in shipped CSS:

     url('data:image/png;base64,AAA=' /* n */)
     progid:DXImageTransform.Microsoft.Matrix(M11=1 /* n */,M12=0)

   Settled where the spans are captured, with the same rule the kill-comment
   loop applies: a "!" comment is kept, any other is deleted.

B. The --line-break pass tracked string state but not comments, so a quote
   inside a preserved "/*! ... */" banner was taken for a string delimiter.
   One unpaired quote inverts the tracking for the rest of the file. Measured
   at --line-break 20, exit 0:

     /*! say "hi */a{content:"aaaa...}bbbb"}c{color:#ff0000}
       -> a newline inserted at the "}" INSIDE the string literal

   A newline in a CSS string is a parse error, so the declaration is dropped.
   With an apostrophe instead, the tracker sticks "inside a string" and every
   later linebreak is suppressed - the harmless half of the same fault. The
   pass now steps over comments before it looks at quotes.

Two further findings of the same class are reported, not fixed, in
round4-report.md: preserveToken's regexes match calc( and progid:...Matrix(
inside strings, and respaceCalcOperators matches calc( inside strings and
comments. They are one problem seen from two ends - fixing either alone moves
the corruption rather than removing it - and that is a design change, not a
patch to land at the end of a release.

513 tests, 0 failures, 3 skipped. All 61 CSS goldens unchanged.
Confirmed unused by reading every reference in compress(), not by trusting the
warning:

  endIndex   assigned 0 and never read or reassigned again.
  totallen   assigned css.length() and never read. Both were the old
             indexOf("/*") loop's cursor and its "endIndex = totallen"
             truncation fallback, which 9b56de5 replaced.
  sb         initialised with new StringBuffer(css) and unconditionally
             reassigned before any read, so the copy was allocated and thrown
             away. Declared without an initialiser instead.

startIndex stays: the IE7 ">/**/" hack branch still reads it.

The third item in the brief - dead code at CssCompressor.java:448 - is not
dead. That line is the "if (end < 0)" guard whose throw rejects an unterminated
comment, and it fires: "a{color:#ff0000} /* oops" exits 1 with "unterminated
CSS comment". Left alone.

513 tests, 0 failures, 3 skipped.
The collectComments javadoc and the CHANGELOG both said the throw fires for "an
unterminated comment outside a string or URL". That was false before this round
- a comment inside a quoted url() could reach it, and did, on valid CSS - and it
would still be false as a summary now that the quoted form is scanned normally.

Replaced with the condition itself rather than a tidier claim: a "/*" reaches
the test when it is not inside a string and not inside an UNQUOTED url() token,
and throws when no "*/" follows it anywhere in the input. Inside a quoted url()
it therefore does reach the test, deliberately, because a comment there is an
ordinary comment.

Also recorded what the throw does not cover, so the next reader does not have to
rediscover it: after an unterminated string or an unclosed "url(", the scan
stops collecting, so later comments are emitted rather than stripped. Malformed
input, and a leak rather than corruption - the safe direction to fail in.

CHANGELOG: the same correction, plus entries for the quoted-url() fix and for
the two scans corrected in 05f036d. Test count measured after every other phase,
503 -> 513. The "46 skipped without node on PATH" claim was re-measured on the
final tree and still holds.

513 tests, 0 failures, 3 skipped.
…R37)

Both are deferred to Release 2 by ruling R37, and both are pre-existing -
byte-identical at the release base 070bdd7 in every form measured. Pinned now so
they cannot drift unnoticed and, more importantly, so neither end can be "fixed"
without the other end announcing itself.

The tests pin WRONG output and say so. A-3: preserveToken's regexes match
"calc(" and "progid:...Matrix(" inside a string, and the captured span's
placeholder is never resolved, because the restoration loop reaches index 0
before the enclosing string - a higher index - is put back:

  a{content:"calc(1px + 2px)"}     -> a{content:"calc(___YUICSSMIN_PRESERVED_TOKEN_0___)"}
  a[data-x="calc(1px + 2px)"]      -> same substitution
  a{font-family:"calc(x)",serif}   -> same substitution
  a{content:"progid:...Matrix(M11=1)"} -> same substitution

A-4: respaceCalcOperators runs after token restoration, so no string, comment or
URL is protected by then:

  /*! calc(1px+2px) */                  -> /*! calc(1px + 2px) */
  url(/x/calc(1px+2px)/y.png)           -> url(/x/calc(1px + 2px)/y.png)

The trap is recorded in the tests, because A-3 has a tempting one-line fix -
resolve the nested reference in the string-preserving pass, which
resolvePreservedTokenReferences already does elsewhere. Landing it alone
restores "calc(1px + 2px)" into the string, whereupon A-4 respaces it to
"calc(1px  +  2px)": visible scaffolding becomes an invisible rewrite of the
author's text, which is worse because it looks plausible. A-3 currently hides
A-4's only valid-CSS string case, which is why the pair has to move together.

The control assertion in the A-4 test pins that ordinary calc() respacing still
works, so a future fix cannot satisfy these tests by disabling the pass.

515 tests, 0 failures, 3 skipped. No behaviour change.
Preserving a data: URL ran token.replaceAll("\\s+", "") over the whole captured
span, including the contents of the quoted string. In a base64 payload that is a
convenience. In any other payload the data is literal, so it destroyed author
bytes - silently, exit 0, on valid CSS:

  url("data:image/svg+xml,<svg viewBox='0 0 24 24'><text>hello world</text></svg>")
    -> viewBox='002424'   and   <text>helloworld</text>

viewBox's grammar is four numbers separated by white space or commas, so
"002424" is one invalid value rather than four; no browser is needed to see the
image is broken. The text node's rendered content changed outright. The
percent-encoded spelling was unaffected, which is how this survived: %20 is the
machine-generated style and the literal space is the hand-written one.

Pre-existing - byte-identical at the release base 070bdd7 - and reported as a
deferral candidate. Reversed to a fix because it destroys data rather than
leaking, which is the line this release drew.

The discriminator needed checking rather than assuming, and "strip only when
base64" turned out to be too strong. Three regions, three answers:

  outside the quotes           always stripped. Two goldens pin this on
                               NON-base64 URLs - dataurl-nonbase64-noquotes has
                               "url( data:...)" and dataurl-nonbase64-doublequotes
                               puts the quoted string on its own line - so it
                               cannot be conditional on the payload.
  inside, base64               stripped. RFC 2397 makes ";base64" the last thing
                               before the comma, after any media-type parameter,
                               and such a payload's white space is insignificant.
                               Load-bearing: dataurl-base64-linebreakindata splits
                               its payload across three lines inside the string.
  inside, anything else        kept. The data is literal.

The unquoted form keeps the old behaviour of losing all its white space, and
nothing legal is lost by that: white space inside an unquoted url-token other
than at the ends makes it a bad-url-token (CSS Syntax L3 4.3.6), so the input is
already invalid.

";base64" is matched case-insensitively and only where RFC 2397 puts it, so
data:text/plain;charset=UTF-8;base64 is recognised and a ";base64" occurring
later, in the data itself, is not - that payload stays literal.

One deliberate consequence, recorded in the javadoc. A NON-base64 quoted data
URL split across lines is no longer joined. A newline inside a CSS string is a
parse error, so that input was already invalid; the join used to repair it by
accident, and the repair is the same operation as the corruption above - delete
a character the author wrote. Preserving is the safe direction.

All 61 CSS goldens unchanged, no fixture edited. 519 tests, 0 failures, 3 skipped.
…isting

The lead's check of my claim that the four leaks are regressions used inputs
with no comments in them, so it could not have measured what the claim was
about. Pinning the comment-bearing forms so the record settles it.

Measured against the release base 070bdd7, which strips the comment in all four:

  a{background:url(x}/* n */b{...}                  unclosed url(
  a{content:"oops}b{...}/* n */c{...}               unterminated string (R33)
  a{content:"oops\n}\nb{...}\n/* n */\nc{...}       string closed by a newline
  a{background:url(/x/y'.png)}/* n */b{...}         stray quote in an unquoted url

All four now emit the comment. So they are REGRESSIONS from 9b56de5, not
pre-existing: the old context-free scan did strip these, by not knowing where it
was - the same blindness that truncated whole stylesheets. Leaking a comment on
malformed input is what that trade bought, and it is a leak, not a rewrite:
nothing is deleted or altered. Every input is invalid CSS.

R32 is the opposite case and is pinned next to them for contrast: byte-identical
at base, so genuinely pre-existing. An unquoted URL whose contents spell a
declaration reaches the value optimisers and has its value rewritten
("--y:0px" -> "--y:0" inside the URL); its control pins that realistic URLs do
not reach it, which is why it was deferred rather than fixed.

No behaviour change. 521 tests, 0 failures, 3 skipped.
The data: URL white space fix joins the Fixed (CSS) list, with the measured
before-and-after, the RFC 2397 rule that decides which payloads may lose their
white space, and the one deliberate consequence - a non-base64 quoted data URL
split across lines is no longer joined.

New "Known limitations (CSS), carried to Release 2" section, naming the whole
remaining cluster in one place. The framing is the honest close for this
release: every entry is one instance of a single defect class - a pass that runs
without knowing what region of the document it is in - and Release 1 fixed every
instance its own changes touched and every instance that destroyed data, then
enumerated the rest rather than leaving them unknown. Each has a regression test
pinning it, so none can drift or be half-fixed unnoticed.

Split into two groups, because they are not equally serious and saying so
matters more than a tidy sentence:

  rewrites confined to one declaration - wrong output, not just unminified
    output: calc(/progid: matched inside strings and left as scaffolding; calc()
    respacing running after restoration; an unquoted URL spelling name:value
    reaching the value optimisers. The first two are entangled, which is the
    reason neither is fixed here rather than a judgement about severity.

  comments that survive into the output - nothing altered or lost, every trigger
    already-invalid CSS: after an unterminated string, after an unclosed url(,
    and after a stray quote in an unquoted url().

The second group is recorded as REGRESSIONS from the structural comment scanner
rather than as pre-existing behaviour, which is what the measurements say.

Test count measured after every change, 513 -> 521. The "46 skipped without node
on PATH" claim re-measured on the final tree and still holds.

521 tests, 0 failures, 3 skipped.
…ollection (R40)

05f036d taught the --line-break pass to step over comments but not over URLs, so
a "/*" inside an unquoted url-token - ordinary URL content, and correctly not a
comment to collectComments - started a phantom comment that ran to the next "*/"
anywhere in the file. Measured at --line-break 10, exit 0:

  a{background:url(/x/*p.png)}
  b{content:"*/z"}
  c{content:"aaaaaaaaaa}bbbbbbbbbb"}
  d{color:#ff0000}

    -> a{background:url(/x/*p.png)}b{content:"*/z"}c{content:"aaaaaaaaaa}
       bbbbbbbbbb"}d{color:red}

The newline lands inside c's string literal. A raw newline in a CSS string is a
parse error, so c is dropped. Valid CSS in, silent corruption out. Also
reachable through an unquoted data: URL; the quoted form was never affected,
because a quote opens a string region either way.

Rather than adding a fourth ad-hoc step to a scanner that has now guessed wrong
three times, the pass asks its question - what region is this offset in - with
the same three primitives collectComments uses, in the same order: skipString,
startsUrlToken/skipUrlToken, then a comment. It is now the only region model in
the file. The loop also builds its output rather than mutating a buffer it is
scanning, which is what lets the shared helpers apply.

That removed a second corruption I did not know about, found by running a
contrived case rather than re-reading the code. "}" is legal inside a url-token
(4.3.6 stops only at ")", whitespace, a quote or "("), so this is valid CSS:

  a{background:url(/x/}p.png)}   at --line-break 10, at d484f4e and before
    -> url(/x/}
       p.png)}

The newline goes inside the URL, which is exactly what a url-token may not
contain, so the declaration is dropped. Pre-existing, not from this range.

Regions enumerated and measured, one case each: preserved comments (including a
"}" inside one, the Mac/IE5 "/*\*/" pair and the IE7 ">/**/" hack), quoted
strings (including an escaped quote and a "}"), quoted URLs, unquoted URLs
(including "/*", "}", data: and @import forms), "myurl(" not being a url token,
a leaked PRESERVED_TOKEN placeholder, and the "\9" hack - all inert or handled.
Controls pin that ordinary rules still break, at --line-break 10 and 0, and that
the 05f036d fix still works.

Every failure mode of the three primitives ends a region LATE - an unterminated
string, URL or comment runs to the end of input. Here that only produces a long
line, which is why the sharing is safe in this direction: refusing to break
cannot corrupt, breaking in the wrong place can. Two residuals stay in that safe
direction and are unchanged: a stray quote inside an unquoted url() and an
unterminated string both suppress later breaks.

One thing is deliberately not shared, and the javadoc says why: collectComments
throws on an unterminated comment, and this pass steps to the end instead. A
comment following an unterminated string is never collected, so it reaches the
output as written; throwing there would fail a compression that has succeeded.

CHANGELOG: the entry claiming "--line-break no longer inserts a newline inside a
string literal" was falsified by the repro above and is rewritten to describe all
three faults and the shared model. Test count measured after the change, 521 ->
524; "46 skipped without node on PATH" re-measured and still holds.

All 61 CSS goldens unchanged, no fixture edited. 524 tests, 0 failures, 3 skipped.
…defect (R41)

Documentation and tests only. The one non-comment line in the source diff is a
single-line javadoc becoming a block.

d9d2ab8 asserted, in CssCompressor.java and in the CHANGELOG, that EVERY failure
mode of the shared region primitives ends a region late. That is the same shape
of guarantee that commit was written to replace, and it is wrong in the same way:
it covers where regions end and says nothing about where they start.

Corrected to the property that actually holds, stated as what was tested rather
than as a property of the design: fuzzing 68,383 (source, width) pairs against an
independent 4.3 tokenizer found no early end. Region STARTS are called out as the
known exception.

The exception, deferred by ruling R41 and now pinned:

  skipString is entered at any quote without either caller asking whether the
  quote is escaped, and "\"" is a valid identifier escape (4.3.7). The phantom
  region ends at the OPENING quote of the next real string, so the scan resumes
  inside it. Both consequences destroy data, on valid CSS, exit 0:

    a\"b{color:red}                       --line-break 10
    c{content:"XXXXXXXXXX}YYYYYYYYYY"}
      -> newline inside c's string literal, so c is dropped

    a\"b{color:red}
    c{content:"keep /* this */ text"}
      -> c{content:"keep text"}, author bytes deleted

Both byte-identical at 070bdd7. Pinned with two controls that guard the fix
rather than the defect: without the escaped quote both cases are correct, and a
backslash PAIR before a quote is NOT an escape, so "previous character is a
backslash" is the wrong repair.

One correction to the ruling's reasoning, which does not change its outcome. The
premise that the trigger is vanishingly rare in authored stylesheets is wrong:
Tailwind's arbitrary-value classes (content-['x']) emit an escaped quote in the
generated selector, so the shape occurs in ordinary machine-generated CSS. I
could not get destruction out of it - measured, the effect there is a span left
unminified, because the destroying variants need a comment or a "}" inside a
later real string and that shape has neither. So the trigger is reachable and the
harm is not, which is the distinction R41 actually rests on; the CHANGELOG and
the tests now say that rather than "rare".

That probe did find one genuine regression from this release, pinned with the
rest: after the phantom string, a following "/*!" banner is no longer collected,
so its text reaches the minifier and loses the space after "!". At 070bdd7 the
context-free scan collected it regardless. Cosmetic, inside a comment, but it is
author text and it is new.

The Known limitations section gains a third group, "Destroys data", above the
existing two, and its introduction no longer claims that nothing in the list
costs a declaration.

526 tests, 0 failures, 3 skipped; 46 skipped without node on PATH. All 61 CSS
goldens unchanged, no fixture edited, no behaviour change.
@marevol marevol self-assigned this Sep 4, 2026
Convert the remaining Japanese prose and in-snippet code comments in
docs/ES6_MIGRATION_PLAN.md to English so all Markdown docs in the
repository are English. Content, structure, checkbox states and code
snippets are unchanged.
Adds seven test classes, 210 tests, all passing against this branch:

- CliOptionsProcessTest    - --charset, --type inference, -m, --line-break,
                             exercised by forking the entry point so exit
                             codes and System.exit are observable
- CompressorApiTest        - the four no-op options and the mungemap
                             overload, none of which any test had passed
- FixtureParseGuardTest    - a tripwire for the two bare-return catch blocks
                             in JsOutputSyntaxTest that JUnit reports as
                             PASSED rather than skipped
- IdempotencyTest          - compress(compress(x)) == compress(x) over the
                             whole fixture corpus, including quarantined ones
- KnownCssLimitationsTest  - pre-existing CssCompressor defects, pinned as
                             wrong output and labelled as such; every case
                             verified byte-identical at the release base
                             070bdd7, so none is a regression here
- RobustnessTest           - malformed and adversarial input handling
- UncoveredBranchGuardTest - branches with no previous execution

Test-only change; no production code is touched.
Each of these was verified by running the same input through a build of main
(070bdd7): main produced the expected value and this branch did not.

CSS
- Empty-rule removal matched an at-rule prelude with a separate '@'-anchored
  alternative, and '@' is an ordinary character in a class name once escaped.
  ".\@container{}p{color:red}" therefore had its only '@' in the middle of a
  plain prelude no alternative could match; the '@' alternative matched from
  the '@' onward and deleting "@container{}" welded the leftover ".\" to the
  next rule, giving ".\p{color:red}". The prelude is now one character class
  and the '@layer' exception is decided on the matched text.
- The prelude is also anchored to the boundary its character class already
  implies, which restores linear time. '@Property' blocks are preserved whole
  by this release, so a stylesheet of them is one long brace-free run that the
  unanchored pattern retried from every offset: 800 of them took 3.2s.
- The custom property scan had no notion of strings or url tokens.
  "url(/x/;--y.png)" put a '--' straight after a ';' so it read as a
  declaration, the search for its ':' ran out of the URL into the next rule's
  "b:hover", and the value scan ran from there to end of input - one URL left
  a whole stylesheet unminified with exit code 0. It now uses the same region
  model as collectComments and insertLineBreaks.

JavaScript
- ScopeBuilder never declared a function's own name, so the munger handed it
  out as free: "function f(x){...}" beside six locals produced "var f=1" and
  the call beside it read the variable. A declaration's name now goes in the
  enclosing scope and a named function expression's in its own, which is also
  what makes a recursive self-call resolve to the function instead of to the
  outer variable. The name is reserved rather than renamed, in the declaring
  scope and every enclosing scope that is munged, because an outer variable
  munged to the same spelling is shadowed inside the function's body.
  jQuery 1.6.4: 104,770 -> 104,815 bytes (0.04%), shape counts unchanged, and
  still 137,795 -> 104,815 against main.
- isStrict() only tested the property for non-null, so
  -Dyuicompressor.strict=false enabled strict mode. It is parsed as a boolean
  now; the CHANGELOG's bare -Dyuicompressor.strict is spelled =true to match.

Command line
- The destination was opened, and so truncated, before the compression that
  can fail. A stylesheet this release declines to guess at left an empty
  output file, and with -o pointing at the input an empty source file: 29
  bytes to 0. The compressed text is produced in full first.
- That refusal left main as an uncaught IllegalArgumentException; it is
  reported as "[ERROR] <file>: <message>" with a non-zero exit.
- The per-file writer wrapped System.out and was closed after the first file,
  so later files in the same run wrote to a closed stream and were discarded.
  Pre-existing on main, fixed here because the same block was being changed.

Full suite: 765 tests, 0 failures, 0 errors, 3 skipped.
The three classes held back from the previous test commit because they were
red. The fixes in the commit before this one make them pass.

- MergeGateRegressionTest  - one minimal input per regression, each verified
                             against a build of main (070bdd7) so the claim
                             "main is right, this branch is not" is measured
                             rather than asserted. Two of the eleven are
                             labelled pre-existing so the regression count
                             stays honest.
- StrictModePropertyTest   - what values of -Dyuicompressor.strict mean.
                             Saves and RESTORES the property rather than
                             clearing it, so it cannot disable strict mode for
                             classes scheduled after it in the same JVM.
- CommandLineProcessTest   - the process contract, by forking a JVM: exit
                             codes, --help, a missing file, an unparseable
                             script, stdout compression, the -o pattern, and
                             what a refusal does to the files involved.

Full suite: 765 tests, 0 failures, 0 errors, 3 skipped.
@marevol marevol added this to the 2.4.11 milestone Sep 4, 2026
@marevol
marevol merged commit 6a7a3bc into main Sep 4, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant