maybeAddImport returns the name it bound, and templates resolve the modules they name - #8680
Merged
Merged
Conversation
`maybeAddImport` was a pure queue push: it appended an `AddImport` to
`visitor.afterVisit` and decided nothing at call time, so it could not tell a
caller what local name the module ended up bound to. Every decision ran later
in `AddImport.visitJsCompilationUnit`.
It now resolves the name at call time by reaching the compilation unit from
`visitor.cursor` and returns it — the name of an import that already binds the
module, or the name the new import will use. Imports and everything that can
shadow one are top-level statements, so this stays synchronous: existence, the
deconfliction pool and the reservation scan are all flat scans of
`cu.statements`. Where no compilation unit is reachable — `maybeAddImport`
called from a visitor constructor, as several tests do — the derived name is
returned without a lookup.
There was no shadowing check anywhere in add-import.ts, so
`maybeAddImport({module: 'fs', member: 'readFile'})` into a file already
declaring a local `readFile` emitted a colliding binding, which is a
SyntaxError. A name derived from `member` or `module` is now deconflicted with
a `_1` suffix against module-scope declarations, existing import bindings, and
names claimed by `AddImport`s already on the queue, less those a queued
`RemoveImport` will free. A bare digit would be ambiguous on names already
ending in one, so `base64` deconflicts to `base64_1`.
An alias the caller pinned explicitly is honoured verbatim, even into a
collision: the caller may already have emitted code naming it, so it owns the
consequence. `ChangeImport` uses that to opt out — it moves a binding rather
than introducing one, and the import it is replacing is still in the tree
`maybeAddImport` reads. Its two named-member branches collapse into one now
that an alias equal to the member prints as a plain specifier.
`AddImport` carries the resolved name as `bindingName`, which is both what it
emits and what `onlyIfReferenced` searches for; previously that search used
`alias || member`, which would have looked for the un-deconflicted name.
Overloads keep the `undefined` return confined to the side-effect path, where
there is no binding to name.
…s-the-name-it-bound
A returned binding name only helps if the caller respects it, and a caller
generating code from a template has to thread it in by hand. A template now
declares the modules its own source names:
template`Theming.setTheme(${theme})`.configure({
bindings: {Theming: {module: 'sap/ui/core/Theming', member: 'default'}}
})
`Template.resolveBindings(visitor)` binds each one in the file being visited and
returns the local names, which the caller hands back through
`ApplyOptions.bindings` or the new `TryOnOptions`. Applying the template rewrites
its references to whatever the file settled on, so a file already importing the
module under another name gets that name, and one that shadows it gets a
suffixed one.
The caller resolves and the engine substitutes, rather than `apply` taking a
visitor: a Template's parsed AST is shared across call sites through
`globalAstCache`, and a cached object that mutates visitor state as a side effect
of being applied is the wrong lifetime. A declared key with no entry in the map
throws, so forgetting is loud rather than a name nothing bound. The declaration
also generates the type-attribution context imports, which `getTemplateTree`
leaves out of the output by taking only the last statement.
The rename runs before parameter substitution, so only the template's own code is
in scope and a caller's captured code is untouched by it. Attribution decides
where the context import resolved; where it did not, position does, and an
identifier in its parent's `name` slot is being named rather than referencing the
binding. `resolveBindings` is safe to call for a node the template turns out not
to apply to, because the import lands in `afterVisit`, by which point the file
references the name only where the template did.
`AddImportOptions` gains `preferredName`: a name that loses to an existing import
and deconflicts against a shadow, where `alias` does neither. That made
`member: 'default'` reachable without an alias, which exposed the reuse lookup
comparing `member` literally — `'default'` and an absent member both name a
default import, and `memberName` normalizes them.
Four comparisons treated a field describing how an import prints, or what name
would be nice, as part of which binding was requested. Each was harmless while
the function returned void and each returned a name no import bound once it did
not: `quoteStyle` and `preferredName` split one request into two that both
survive the queue dedup, while `isMatchingImport` and `isMatchingRequire` stayed
on `alias` after emission moved to `bindingName`, merging a second `readFile`
specifier into an import that already had one.
`TemplateOptions.bindings` names modules and members only, and resolution is a
separate call, so nothing in the rename pass is ESM-specific.
…s-the-name-it-bound
MBoegers
approved these changes
Aug 27, 2026
… it matches
`moduleScopeBindings` recorded a `require()` binding without the module it comes
from, so the reuse lookup could not match one. A file holding
`const {join} = require('path')` therefore had `join` counted as an occupied
name rather than as the very binding being asked for:
maybeAddImport(v, {module: 'path', member: 'join'})
// import {join as join_1} from 'path'; alongside the require
Recording the module and member for both `const F = require('m')` and
`const {k: kk} = require('n')` lets a require answer a request the way an import
does, under whatever name it destructured. The same gap made a default binding
with a `preferredName` return a name that `isMatchingRequire` then declined to
emit.
Namespace and type declarations join the pool. They shadow an import exactly as
a `const` does, and an import that collides with one is TS2440.
The `afterVisit` scan now mirrors the file-side rule: a queued import binds the
module as much as one already present, so it answers a later request for the
same module and member. Comparing `member` literally missed that `'default'` and
an absent member name the same import, which left a second request holding a
name the merged one never emits.
`TryOnOptions.visitor` lets a rule resolve its own template's declared modules,
since it holds that template. It resolves once a pattern has matched, so a rule
that does not fire leaves the file's imports alone rather than reserving a name
that pushes an unrelated binding aside. A caller that resolves some other way
still passes `bindings`.
`Template.apply` keeps its own documentation, and `resolveBindings` sits above
it with its own.
Reuse answered a request from any binding of the same module and member, whatever
local name it carried. A caller that named no preference is assuming the name it
derived, so handing back another one left the references it emits unbound:
maybeAddImport(this, {module: 'react', member: 'useState', onlyIfReferenced: false});
// on: import {useState as useS} from 'react';
// returned 'useS' and added nothing, so a recipe emitting useState(...) bound nothing
`rewrite-react`, `rewrite-nodejs`, `javascript-recipe-starter` and
`recipes-testing-frameworks` all call `maybeAddImport` this way and all ignore the
return value, so the emitted import is the whole contract for them.
A `preferredName` or an `alias` is the caller saying it will take whatever name
comes back. Without one, only a binding under the very name the request derived
answers it, and anything else falls through to the ordinary path — which adds the
member as a second specifier under the name the caller expects.
Reuse across a different name still holds where it was designed to: `bindModule`
and `Template.resolveBindings` both name a preference.
`bindingContextStatement` prepended `import X from '<module>';` to every template
that declared a binding. The import is what carries attribution, and it costs a
module resolution to get it, measured over 12 distinct templates on a warm parser:
no bindings 49 ms/template
bindings, plain import 112 ms/template
For a module no `dependencies` entry covers there is no workspace to resolve it
against, so the resolution is paid and nothing comes back. Those bindings now
parse against `declare const X: any;` — or `type X = any;` where the binding is
type-only — which brings the cost back to 31 ms/template.
`renameBindings` reads attribution first and falls to the identifier's position
without it, so the rename is unaffected. `onlyIfReferenced` is not: recognising
the reference a template splices in is precisely an attribution question, and
searching for a name that can carry none finds nothing and drops the import. So
`resolveBindings` binds an unresolvable module unconditionally, which is sound
where the template is known to apply — `tryOn` resolves only once its pattern has
matched, and its own test covers a rule that never fires.
`ModuleBinding.member` and `typeOnly` now say they shape the import
`resolveBindings` creates, since a caller supplying its own `bindings` reads
neither.
… member
`renameBindings` judges an unattributed identifier by position, and read every
identifier in its parent's `name` slot as being named rather than referenced. A
call selects a member from something, so its `name` is that member — but a call
with nothing to select from names no member, and `merge(a, b)` puts the function
being called in exactly that slot. Bare call callees were therefore left alone.
The template rendered the name it declared while the import bound another, which
is silent where a file already binds the declared name to something else:
template`merge(${a}, ${b})`.configure({bindings: {merge: {module: 'sap/base/util/merge'}}})
// const merge = 1;
import merge_1 from 'sap/base/util/merge';
merge('dark', {}); // calls the local, not the module
Attribution had been covering it: a context import gave the identifier an owner
tracing to the module, and the rename took that branch without consulting
position. Parsing an unresolvable module against a declaration instead left
position to decide alone, where it was wrong.
Found by the UI5 port, which renders `jQuery.sap.extend` through `merge($2, $3)`
into blocks that bind `merge` to something else.
Two walks of an object binding pattern had drifted apart. The one behind plain
declarations recursed; the one behind requires read only a flat identifier, so
`const {a: {b}} = require('m')` put nothing in the pool and an import was free to
bind `b` a second time:
maybeAddImport(v, {module: 'm', member: 'b'})
import {b} from 'm'; // alongside the require, binding `b` twice
`patternNames` is now the single walk. A name bound directly by the pattern reads
a member of the module; anything deeper reads a property of a property, so it
occupies its name without the module answering for it.
Three predicates decided what a `require` call is, and each had tightened a
different clause: one demanded no `select`, another a string specifier, the third
neither. `obj.require('fs')` was a require to one reader and not another.
`requiredModuleOf` decides once, demanding both.
A merged specifier sorted by `alias || member` while its neighbours sorted by the
name they bind, so a request naming a `preferredName` — which every template
binding does — sorted by the wrong key.
A pinned alias is settled by the queue scan alone, so the pool it used to build
and discard is gone, along with the three separate places that encoded "an alias
skips this step". `ChangeImport` pins on every call site and paid that walk each
time.
`derivedName` is the one place the fallback order is written, rather than two
four hundred lines apart, and `AddImport.preferredName` is gone — its only reader
was its own constructor.
Two readers enumerated a compilation unit's bindings. `moduleScopeBindings` and
its `importBindings`/`requireBindings` answer at call time, for the name a request
will be bound to; `isMatchingImport`/`isMatchingRequire` answered at apply time,
for whether the import is already there. Both walked the same nodes for the same
fields, and they drifted three times over this branch — a specifier's bound name
computed as `aliasName ?? importName` in one and as `name` in the other, three
disagreeing spellings of what a `require` call is, and a nested binding pattern
one recursed into and the other did not.
The matchers now query the enumeration rather than repeating it. What stays
separate is the question, because the two really are different: the reuse lookup
asks what a request will be called, `answeredBy` asks whether a binding already
serves it. What they can no longer disagree about is the file.
The asymmetry that made the matchers look special was a default import the
request named nothing for, which any default import of the module satisfied
through a bare `return true`. That is `anyNameAnswers`, said once.
Merge ordering read a specifier's bound name through its own pair of helpers;
`specifierBinding` is the reader the pool already used. With the last callers
gone, `getImportName`, `getImportAlias`, `isRequireCall` and
`getModuleNameFromRequire` go with them.
Two shapes tighten as a result, both toward what the file actually binds:
`const {foo: readFile} = require('fs')` no longer answers a request for member
`readFile`, since it binds `fs.foo`, and a name reached through a nested pattern
answers for no member at all.
knutwannheden
marked this pull request as draft
August 27, 2026 14:52
knutwannheden
marked this pull request as ready for review
August 27, 2026 14:59
The pool held what the file binds at module scope. A template lands somewhere
inside that file, though, and a name declared between there and the top is the
one its code will reach:
import merge from 'sap/base/util/merge';
function handler(merge) {
return merge('x', {}); // the parameter, not the import
}
`bindingsInScope` walks the cursor out to the compilation unit and counts what
each frame declares — a function's parameters, a lambda's, a block's
declarations — as occupying the name without the module answering for it. The
statement scan the compilation unit already used serves a block unchanged.
This reaches what encloses the anchor, which is not yet all of it: `var` and
function declarations bind across their whole function, so one inside a sibling
block is in scope at the anchor and is not seen here. A name that reaches the
anchor from a scope this misses still shadows, so that stays wrong until a scope
utility answers reach per declaration kind rather than per frame.
`{module: 'lodash'}` names no local name and asks for no member, so whatever the
file already calls that module answers it. `AddImport` read it that way and
skipped emitting; the reuse lookup did not, and handed back a name nothing binds:
maybeAddImport(v, {module: 'lodash', onlyIfReferenced: false})
// on `import Foo from 'lodash';` returned "lodash", emitted nothing
The two were separate spellings of one question, so `anyNameAnswers` is now the
function both read. A caller that named a preference is still answered only by an
exact name at emission, which is deliberate: by then the name has been resolved
and a fresh binding is what is being added.
Whether a call is a `require` is a question about its shape, and which module it
loads is a further question about its argument. Folding the first into the second
narrowed style detection, which asks only the first — a file whose sole require
passes a variable stopped counting as CommonJS. `isRequireCall` answers the shape
and `requiredModuleOf` builds on it. No output moves today, since the CommonJS
style falls back to ES6 until require creation exists.
Option validation ran after the queue scan, so a request the queue could answer
was never checked. It is a property of the request, so `validate` runs first.
`patternNames` read object binding patterns and stopped there, so the names an
array pattern introduces were absent from the pool and an import was free to bind
one of them again:
// const [Element] = window;
maybeAddImport(v, {module: 'sap/ui/core/Element', member: 'default', preferredName: 'Element'})
import Element from 'sap/ui/core/Element'; // Identifier 'Element' has already been declared
An array pattern binds by position, so unlike an object pattern its elements name
no member of whatever they destructure — they occupy their names and nothing
answers for them. Nesting works in either direction, so `const [{Deep}] = window`
is counted too.
jkschneider
approved these changes
Aug 27, 2026
This was referenced Aug 27, 2026
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.
maybeAddImportreturnedvoid. It was a pure queue push — it appended anAddImporttovisitor.afterVisitand decided nothing at call time — so it could not tell a caller what local name the module ended up bound to, and there was no shadowing check anywhere inadd-import.ts. A recipe that wanted to reference an import it was adding had to guess the name, and guessed wrong whenever the file already used it:That output is a
SyntaxError—readFileis bound twice.What it returns
maybeAddImportnow reaches the compilation unit fromvisitor.cursorand returns the local name the module is bound to. Imports, and everything that can shadow one, are top-level statements, so this stays synchronous: existence, the deconfliction pool and the queue reservations are all flat scans ofcu.statements.Resolution runs in three steps. An import already binding this module and member answers with its own name and queues nothing. An
aliasthe caller pinned is honoured verbatim. Otherwise the derivedmember ?? moduleis deconflicted with a_1suffix — a bare digit reads wrong on a name that already ends in one, sobase64deconflicts tobase64_1.The first step is conditional, and the condition is what the caller asked for. A
preferredNameor analiassays it will take whatever name comes back; a bare{module, member}says it is assuming the name ismember. So a binding under some other name answers only the first kind:Answering
useSthere would leave everyuseState(...)the caller emits unbound.rewrite-react,rewrite-nodejs,javascript-recipe-starterandrecipes-testing-frameworksall call it this way and all ignore the return value, so the import it emits is the whole contract for them.The pool is module-scope declarations — including namespaces and type aliases, which shadow an import just as a
constdoes — existing import andrequire()bindings, and names claimed byAddImports already on the queue. A queuedRemoveImportdeliberately frees nothing: it removes only what the file leaves unused, so binding a name it keeps is an error, where an unnecessary suffix merely reads oddly.Pinning is the opt-out, and
ChangeImportuses it. It moves a binding rather than introducing one, and the import it is replacing is still present in the treemaybeAddImportreads:An alias equal to the member prints as a plain specifier, so pinning a name does not disfigure the output. Its two named-member branches collapse into one as a result.
AddImportcarries the resolved name asbindingName, which is both what it emits and whatonlyIfReferencedsearches for — that search usedalias || member, which would have looked for the un-deconflicted name.Overloads keep the
undefinedreturn confined to the side-effect path, where there is no binding to name. The third is load-bearing: call sites passing anAddImportOptions-typed variable rather than a literal match neither of the first two.Templates declare the modules they need
A returned name only helps if the caller respects it, and a caller writing a template has to thread it into generated code by hand. A template now declares its modules, keyed by the identifier its own source uses:
The file decides the name; the template follows it. Where the file already imports the module, the existing name wins and no import is added:
The rule holds the template, so it resolves the declared modules itself — and does so only once a pattern has matched, which is why a rule that never fires leaves the file's imports alone.
Template.applystill takes resolved names rather than a visitor and stays pure: aTemplate's parsed AST is shared across call sites throughglobalAstCache, and a cached object that mutates visitor state as a side effect of being applied is the wrong lifetime. A caller resolving some other way — keyed by module rather than by template name, say — passesbindingsinstead, and a declared key with no entry throws rather than emitting a name nothing bound. The declaration also decides what the template's own source is parsed against, which never reaches the output becausegetTemplateTreetakes only the last statement.The rename runs before parameter substitution, so only the template's own code is in scope and a caller's captured code is never rewritten by it. Where
dependenciescovers the module, the binding is parsed against an import, which attributes its identifier — aVariablewhose owner is the module — and the rename keys on that. Where nothing could resolve the module, it is parsed againstdeclare const X: any;and position decides instead: an identifier in its parent'snameslot is being named rather than referencing the binding.Declaring a binding does not type the rest of the template's code:
setThemeabove is unresolved either way, and a template that needs it typed addsdependenciesalongside, as it would for a hand-writtencontextentry.That split is a cost decision. An import costs a module resolution — 49 ms per template without bindings against 112 ms with, over 12 distinct templates on a warm parser — and where no
dependenciesentry covers the module it is paid for nothing. The declaration brings it back to 31 ms. It is not free either: recognising the reference a template splices in is itself an attribution question, soresolveBindingsbinds an unresolvable module whether or not the template goes on to reference it. It is sound becausetryOnresolves only once its pattern has matched.Template.resolveBindings(visitor)is public for those callers, and is safe to call for a node the template turns out not to apply to: the import lands inafterVisit, by which point the file references the name only where the template did.TemplateOptions.bindingsnames modules and members only, and resolution is a separate call the caller makes, so nothing in the rename pass is ESM-specific — an AMD binding scheme substitutes its own resolver and handsapplythe same shape of map.One bug shape, four times
Returning a name turned every identity comparison in
add-import.tsinto a correctness surface. Each of these was harmless while the function returnedvoid, and each produced a returned name that no import bound once it did not:quoteStylepreferredNameisMatchingImportaliasName === this.aliasbindingNameisMatchingRequirename === (this.alias || this.member)The first two split one request in two. Both calls survive dedup, the second derives a fresh name, and only one import lands:
Reachable as soon as two templates in one recipe declare the same module under different keys. Neither field says which binding is wanted, so both are out of the comparison;
aliasstays, because two aliases for one member are two legal bindings.The last two are the mirror image — matching left behind on a field emission had moved off. Pinning an alias equal to the member meets a plain specifier whose
aliasNameisundefined:Both now compare the bound name:
(aliasName ?? importName) === this.bindingName.Tests
Fourteen under
describe('bound name')inadd-import.test.ts, seven intemplate-bindings.test.ts, each pinning a distinct line, one per bug above. The templating six were mutation-checked: breakingnamesItsParentkills exactly the naming-position test, and making the rename a no-op kills the other four.Three of the four bugs came out of review rather than from these tests, because each needed a caller shape the suite had never used. That is why the diff is larger than "add a return type".
Scope
The pool is read from the tree as it was when the visit began, so a binding the enclosing visitor removed inline in the same pass still looks occupied. That is not closeable at call time —
ChangeImportopts out by pinning, and a caller that removes throughmaybeRemoveImportputs the freed name on the queue where it is visible. A module specifier that is not a valid identifier still has nothing to derive a default name from, somaybeAddImport({module: '@scope/pkg'})emits an unusable import — unchanged here, and closed by passingaliasorpreferredName.