Skip to content

A scope utility for JavaScript LSTs, and the import recipes use it - #8686

Merged
knutwannheden merged 3 commits into
mainfrom
a-scope-utility-for-javascript-lsts
Aug 27, 2026
Merged

A scope utility for JavaScript LSTs, and the import recipes use it#8686
knutwannheden merged 3 commits into
mainfrom
a-scope-utility-for-javascript-lsts

Conversation

@knutwannheden

@knutwannheden knutwannheden commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

maybeAddImport deconflicts the local name it returns against what the file already binds, and read reach from the frame a declaration sits in rather than from the declaration itself. Two shapes came out wrong, and both emit a reference that silently resolves to a local instead of the import.

A var or function declaration binds its whole function, so one in a block that does not enclose the anchor is still in scope there — and the walk only looked at frames that do enclose it:

function f() {
    if (x) { var merge = 1; }   // binds `merge` across all of f
    anchor();                   // emitting `merge` here reaches the var, not an import
}

Asking about the anchor at all is the deeper problem. An import binds at module scope, and the afterVisit queue answers every later request for that module with the name the first one chose, so one name serves reference sites that are not known when it is picked:

function free()          { return foo('x'); }
function shadowed(merge) { return foo('y'); }

A rule firing at both sites takes plain merge from the first, where nothing shadows it, and the reference in the second then reads the parameter. Reproduced with two maybeAddImport calls for one module; it predates this branch, since the pool used to scan compilation-unit statements only and never saw the parameter either.

The utility

scope.ts answers both questions a file asks, off one declaration reader:

scopeOf(cursor).declares('merge')   // in scope at a point: shadowing checks, renaming
namesDeclaredIn(cu)                 // every name the file binds anywhere

scopeOf resolves by declaration kind. Each frame contributes what it binds — a block its declarations, a function its parameters, a catch its parameter, a loop its control bindings, a class its own name — and every function frame also contributes what the blocks under it hoist out. That hoisting walk is generic and bounded by the function, so a shape it has not been taught still yields its declarations; only a let, const or using keyword takes a name out of it. Where reach cannot be read the name is counted rather than missed, because counting one in error costs a suffix while missing one emits a reference that binds to the wrong thing.

Why maybeAddImport uses the file-wide answer

Not as a blunt over-approximation of per-site analysis, but because it is the same set: every name in scope at some point is declared somewhere in the file, and every name declared somewhere is in scope at least within its own scope, so the union of the in-scope sets over all program points is the set of declared names, modulo globals that deconfliction never considered. It is exact for the question as posed, and over-approximate only relative to the sites that turn out to fire.

It also removes the conditions for the bug rather than patching it: with one answer per file, the queue's file-wide caching becomes correct. A distinct binding per site was rejected because it means several imports of one module under different aliases; re-resolving per site was rejected because reconciling already-emitted references is what the queue exists to avoid.

Class member names are left out. A method named merge shadows nothing at any site — bare merge inside it resolves to module scope — so counting it would suffix imports against get, render and merge throughout class-heavy code. What a member's own code declares is still counted.

Tests

17 tests exercise the utility directly, one per decision, plus two through maybeAddImport: the reported two-site defect, and a var in a sibling block. Every arm was mutation-checked by disabling it and confirming a test fails. That caught three tests that pinned nothing — the class-name case passed because a top-level class K is bound by the compilation unit too, and the catch-parameter arm was dead because the hoisting walk was wrongly treating a catch parameter as hoisting, which would also have marked e taken across a whole function. Both are fixed and now covered.

Performance

Measured before caching rather than presumed. scopeOf at 500 call sites in a 200-function module cost 1323ms against a 33ms traversal baseline, and namesDeclaredIn for 500 imports into that file cost 3761ms; each walk is now remembered against the subtree it reads, giving 143ms and 11ms. Keyed by node identity in a WeakMap, so a replaced subtree is a miss rather than a stale hit.

A second consumer

maybeRemoveImport had the same class of defect, found by surveying for hand-rolled scope reasoning once the utility existed. A require destructuring a member under another name kept it, whatever the request asked for:

maybeRemoveImport(v, 'fs', 'readFile');
const {readFile: rf, writeFile} = require('fs');   // unchanged

The pattern element yielded one string — the local name — and that was matched against the member being removed. The two coincide only for shorthand, so shorthand worked and everything else silently did nothing; a nested pattern yielded '' and never matched either. bindingNames reports both, so the element now states the member it reads and the name it binds. A name a nested pattern binds reads a property of a property, so it reads no member of the module at all and is kept.

That distinction is why the member parameter has no default: an argument passed explicitly as undefined takes the default value, which is exactly what a nested pattern passes to say it reads nothing. Mutation-checking caught that — the fix looked right and the test still passed for the wrong reason.

Scope

patternNames moves out of add-import.ts into scope.ts as bindingNames, extended to array patterns and rest names, so one reader of a binding pattern now serves add-import.ts, remove-import.ts and the scope walk — that file has been bitten before by two walkers over one shape drifting apart. ChangeImport renames a specifier onto a name the file may already bind, producing a duplicate declaration; that needs alias-form and reference-rewriting decisions beyond this change and is left for a follow-up. Follows #8680. Full suite: 2031 passing.

Reach is a property of a declaration kind, not of the frame holding it, so
walking out from a cursor and collecting what each frame declares directly
answers only part of the question. `var` and function declarations reach the
whole function they sit in, which puts one in a sibling block in scope at the
cursor without ever enclosing it:

    function f() {
        if (x) { var merge = 1; }   // binds `merge` across all of f
        anchor();                   // `merge` here is the var
    }

`scopeOf(cursor)` answers by kind. Each frame contributes what it binds — a
block its declarations, a function its parameters, a catch its parameter, a loop
its control bindings, a class its own name — and every function frame also
contributes what the blocks under it hoist out. That hoisting walk is a generic
one bounded by the function, so a shape it has not been taught still yields its
declarations; only a `let`, `const` or `using` keyword takes a name out of it. A
shape whose reach cannot be read is counted rather than missed, because a name
counted in error costs a suffix while a name missed emits a reference that binds
to the wrong thing.

`namesDeclaredIn(cu)` answers the question a file-scoped binding asks instead:
not what is in scope at one point, but every name the file binds anywhere. Such
a binding is referenced from sites that are not known when it is named, and the
union of what is in scope across all of them is what the file declares. A class
member is left out, being reached through an instance rather than by name.

`bindingNames` reads a binding pattern once for every caller, and covers array
patterns and rest names alongside the object patterns.

Both walks are remembered against the subtree they read. Asking a 500-call-site
function what it hoists at every site took 1323ms of an otherwise 33ms
traversal, and asking a 200-function file what it declares 500 times took
3761ms; kept, those are 143ms and 11ms.
The pool counted what each frame between the anchor and the compilation unit
declares directly, which left the gap that walk was named for: a `var` or
function declaration binds its whole function, so one in a sibling block is in
scope at the anchor and the walk never looked there.

Asking about the anchor at all is the deeper problem. An import binds at module
scope, and the queue answers every later request for that module with the name
the first one chose, so a single name serves reference sites that are not known
when it is picked:

    function free()          { return foo('x'); }
    function shadowed(merge) { return foo('y'); }

A rule firing at both sites takes plain `merge` from the first, where nothing
shadows it, and the reference in the second then reads the parameter. A name one
binding shares has to clear every scope in the file, which is what
`namesDeclaredIn` reports.

That splits the two questions the binding list answered at once. Only an import
or `require` at module scope answers for a name, so the reuse lookup reads module
scope alone and `moduleScopeBindings` narrows to the imports and `require`s that
carry a module. Every other declaration it used to scan is a name the file binds
and nothing more.

`patternNames` moved to `scope.ts` as `bindingNames`, leaving one reader of a
binding pattern for the two callers that need one.
A `require` destructuring a member under another name kept it, whatever the
request asked for:

    maybeRemoveImport(v, 'fs', 'readFile');
    const {readFile: rf, writeFile} = require('fs');   // unchanged

The pattern element yielded one string, the local name, and that string was
matched against the member being removed. The two coincide only where the
binding is shorthand, so the shorthand case worked and every other one silently
did nothing. A nested pattern yielded the empty string and so never matched
either. The named-import path already reads the two apart, matching the member
and checking the local name for uses.

`bindingNames` reports both, so the element states the member it reads and the
name it binds, and `shouldRemoveImport` is given each. A name a nested pattern
binds reads a property of a property and so reads no member of the module at
all, which now says to keep it rather than saying its member is its own name.

That distinction is what the member parameter carries, so it has no default: an
argument passed explicitly as `undefined` takes a default value, which is
precisely what a nested pattern passes to say it reads nothing.
@knutwannheden knutwannheden changed the title A scope utility for JavaScript LSTs, and maybeAddImport clears every scope its binding reaches A scope utility for JavaScript LSTs, and the import recipes use it Aug 27, 2026
@knutwannheden
knutwannheden merged commit 4d1f0a9 into main Aug 27, 2026
1 check passed
@knutwannheden
knutwannheden deleted the a-scope-utility-for-javascript-lsts branch August 27, 2026 20:08
@github-project-automation github-project-automation Bot moved this from In Progress to Done in OpenRewrite Aug 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

1 participant