Skip to content

Fix underscore-pileup collisions with scope-aware resolution#754

Open
megaboich wants to merge 1 commit into
jehna:mainfrom
megaboich:fix/collision-resolution
Open

Fix underscore-pileup collisions with scope-aware resolution#754
megaboich wants to merge 1 commit into
jehna:mainfrom
megaboich:fix/collision-resolution

Conversation

@megaboich

@megaboich megaboich commented Jul 18, 2026

Copy link
Copy Markdown

What & why

When un-minifying with a small local model, the renamer produced identifiers with huge runs of leading underscores — up to ~320 in a real run:

________internalState
___________________________________________index   // ~40+ underscores

The bug

The old collision loop deduped final names globally across the whole file and disambiguated by prepending _. A small local model tends to return the same generic word for hundreds of unrelated variables (index was chosen ~1500× in one run, plus node, link, iterator, …). Every repeat collided with the previous one, so the loop kept prepending _ until it found a free slot.

This is over-conservative. Two variables named index in disjoint / sibling scopes are perfectly legal JavaScript and never conflict — only names visible in the same scope chain actually need disambiguation.

The fix

Replace the file-global taken: HashSet<String> with a CollisionResolver that checks a candidate only against names visible in the binding's scope chain:

  1. Same scope / ancestors — via Scoping::find_binding (walks up the scope tree): catches same-scope redeclaration and shadowing that would capture an outer binding.
  2. Descendants — via an Euler-tour interval over the scope tree plus a live name → enter-indices index (O(log n) range query): prevents renaming an outer symbol to a name a nested scope already binds, which would otherwise capture the outer symbol's references made inside that nested scope.

Genuine conflicts now get a numeric suffix instead of an underscore prefix. If the candidate already ends in a number, that number is incremented rather than a second one appended:

  • indexindex2index3
  • foo2foo3 (never foo22)

Behavior change

  • Collisions render as index2 instead of _index. Non-colliding names are unaffected.
  • No public API change: rename_all_identifiers still returns Result<String, RenameError>.
  • The architecture is unchanged — renames still go through Scoping::rename_symbol with the final Codegen::with_scoping materialization; no AST mutation of bindings.

Tests

New coverage (unit tests in collision.rs, integration tests in walker.rs):

  • identical names in sibling scopes stay bare (no suffix);
  • same-scope and shadowing conflicts get exactly one numeric suffix;
  • no pileup across many disjoint scopes (regression for ________index);
  • descendant capture is prevented (outer ax where a nested scope binds x becomes x2, preserving the reference);
  • suffix increment-vs-append rules (item2item3item4, never item22).

All existing tests updated where they asserted the old _-prefix behavior. cargo test, cargo clippy --all-targets, and cargo fmt --check are green.

The collision loop deduped final names globally across the whole file
and disambiguated by prepending `_`. Because a local model returns the
same generic word for hundreds of unrelated variables, each repeat
collided with the previous one and the loop kept prepending `_`,
producing names with up to ~320 leading underscores (e.g.
`___...___index`).

This is over-conservative: two variables named `index` in disjoint /
sibling scopes are perfectly legal JS and never conflict. Only names
visible in the same scope chain need disambiguation.

Replace the file-global `taken: HashSet<String>` with a
`CollisionResolver` that checks a candidate only against names actually
visible in the binding's scope chain:

  - same scope / ancestors, via `Scoping::find_binding` (walks up), and
  - descendants, via an Euler-tour interval over the scope tree plus a
    live `name -> enter-indices` index (O(log n) range query), so an
    outer symbol is never renamed to a name a nested scope would
    capture.

Genuine conflicts get a numeric suffix instead of an underscore prefix.
If the candidate already ends in a number, that number is incremented
rather than a second one appended: `index` -> `index2` -> `index3`, and
`foo2` -> `foo3` (never `foo22`).

Tests: identical names in sibling scopes stay bare; same-scope and
shadowing conflicts get exactly one suffix; no pileup across many
disjoint scopes; descendant capture is prevented; and suffix
increment-vs-append rules.
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