A scope utility for JavaScript LSTs, and the import recipes use it - #8686
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
maybeAddImportdeconflicts 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
varor 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:Asking about the anchor at all is the deeper problem. An import binds at module scope, and the
afterVisitqueue 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:A rule firing at both sites takes plain
mergefrom the first, where nothing shadows it, and the reference in the second then reads the parameter. Reproduced with twomaybeAddImportcalls 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.tsanswers both questions a file asks, off one declaration reader:scopeOfresolves 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 alet,constorusingkeyword 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
maybeAddImportuses the file-wide answerNot 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
mergeshadows nothing at any site — baremergeinside it resolves to module scope — so counting it would suffix imports againstget,renderandmergethroughout 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 avarin 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-levelclass Kis 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 markedetaken across a whole function. Both are fixed and now covered.Performance
Measured before caching rather than presumed.
scopeOfat 500 call sites in a 200-function module cost 1323ms against a 33ms traversal baseline, andnamesDeclaredInfor 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 aWeakMap, so a replaced subtree is a miss rather than a stale hit.A second consumer
maybeRemoveImporthad the same class of defect, found by surveying for hand-rolled scope reasoning once the utility existed. Arequiredestructuring a member under another name kept it, whatever the request asked for: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.bindingNamesreports 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
undefinedtakes 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
patternNamesmoves out ofadd-import.tsintoscope.tsasbindingNames, extended to array patterns and rest names, so one reader of a binding pattern now servesadd-import.ts,remove-import.tsand the scope walk — that file has been bitten before by two walkers over one shape drifting apart.ChangeImportrenames 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.