Skip to content

maybeAddImport returns the name it bound, and templates resolve the modules they name - #8680

Merged
jkschneider merged 13 commits into
mainfrom
maybeaddimport-returns-the-name-it-bound
Aug 27, 2026
Merged

maybeAddImport returns the name it bound, and templates resolve the modules they name#8680
jkschneider merged 13 commits into
mainfrom
maybeaddimport-returns-the-name-it-bound

Conversation

@knutwannheden

@knutwannheden knutwannheden commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

maybeAddImport returned void. It 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, and there was no shadowing check anywhere in add-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:

maybeAddImport(this, {module: 'fs', member: 'readFile', onlyIfReferenced: false});
// before                      // after
const readFile = 1;            import {readFile} from 'fs';
                               const readFile = 1;

That output is a SyntaxErrorreadFile is bound twice.

What it returns

maybeAddImport now reaches the compilation unit from visitor.cursor and 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 of cu.statements.

override async visitJsCompilationUnit(cu: JS.CompilationUnit, p: any): Promise<J | undefined> {
    const readFile = maybeAddImport(this, {module: 'fs', member: 'readFile', onlyIfReferenced: false});
    // readFile === 'readFile_1', and the emitted import binds that name
    return super.visitJsCompilationUnit(cu, p);
}
// before                      // after
const readFile = 1;            import {readFile as readFile_1} from 'fs';
                               const readFile = 1;

Resolution runs in three steps. An import already binding this module and member answers with its own name and queues nothing. An alias the caller pinned is honoured verbatim. Otherwise the derived member ?? module is deconflicted with a _1 suffix — a bare digit reads wrong on a name that already ends in one, so base64 deconflicts to base64_1.

The first step is conditional, and the condition is what the caller asked for. A preferredName or an alias says it will take whatever name comes back; a bare {module, member} says it is assuming the name is member. So a binding under some other name answers only the first kind:

maybeAddImport(this, {module: 'react', member: 'useState', onlyIfReferenced: false});
// before                                  // after
import {useState as useS} from 'react';    import {useState as useS, useState} from 'react';

Answering useS there would leave every useState(...) the caller emits unbound. rewrite-react, rewrite-nodejs, javascript-recipe-starter and recipes-testing-frameworks all 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 const does — existing import and require() bindings, and names claimed by AddImports already on the queue. A queued RemoveImport deliberately 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 ChangeImport uses it. It moves a binding rather than introducing one, and the import it is replacing is still present in the tree maybeAddImport reads:

maybeAddImport(this, {module: newModule, member: newMember, alias: aliasToUse ?? newMember, onlyIfReferenced: false});

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.

AddImport carries the resolved name as bindingName, which is both what it emits and what onlyIfReferenced searches for — 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. The third is load-bearing: call sites passing an AddImportOptions-typed variable rather than a literal match neither of the first two.

export function maybeAddImport(v: JavaScriptVisitor<any>, o: AddImportOptions & {sideEffectOnly: true}): undefined;
export function maybeAddImport(v: JavaScriptVisitor<any>, o: AddImportOptions & {sideEffectOnly?: false}): string;
export function maybeAddImport(v: JavaScriptVisitor<any>, o: AddImportOptions): string | undefined;

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:

const tmpl = template`Theming.setTheme(${theme})`.configure({
    bindings: {Theming: {module: 'sap/ui/core/Theming', member: 'default'}}
});
const rule = rewrite(() => ({before: pattern`applyTheme(${theme})`, after: tmpl}));

override async visitMethodInvocation(m: J.MethodInvocation, p: any): Promise<J | undefined> {
    m = await super.visitMethodInvocation(m, p) as J.MethodInvocation;
    return await rule.tryOn(this.cursor, m, {visitor: this}) || m;
}
// before                      // after
const Theming = 1;             import Theming_1 from 'sap/ui/core/Theming';
applyTheme('dark');            const Theming = 1;
                               Theming_1.setTheme('dark');

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:

// before                                        // after
import Th from 'sap/ui/core/Theming';            import Th from 'sap/ui/core/Theming';
applyTheme('dark');                              Th.setTheme('dark');

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.apply still takes resolved names rather than a visitor and stays pure: 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 caller resolving some other way — keyed by module rather than by template name, say — passes bindings instead, 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 because getTemplateTree takes 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 dependencies covers the module, the binding is parsed against an import, which attributes its identifier — a Variable whose owner is the module — and the rename keys on that. Where nothing could resolve the module, it is parsed against declare const X: any; and position decides instead: an identifier in its parent's name slot is being named rather than referencing the binding.

template`Theming.setTheme(${props}.Theming)`   // →  Theming_1.setTheme(props.Theming)

Declaring a binding does not type the rest of the template's code: setTheme above is unresolved either way, and a template that needs it typed adds dependencies alongside, as it would for a hand-written context entry.

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 dependencies entry 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, so resolveBindings binds an unresolvable module whether or not the template goes on to reference it. It is sound because tryOn resolves 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 in afterVisit, by which point the file references the name only where the template did.

TemplateOptions.bindings names 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 hands apply the same shape of map.

One bug shape, four times

Returning a name turned every identity comparison in add-import.ts into a correctness surface. Each of these was harmless while the function returned void, and each produced a returned name that no import bound once it did not:

where compared with
queue dedup quoteStyle a field that decides how the specifier prints
queue dedup preferredName a field that decides what name would be nice
isMatchingImport aliasName === this.alias raw alias, after emission moved to bindingName
isMatchingRequire name === (this.alias || this.member) same, in the CommonJS path

The first two split one request in two. Both calls survive dedup, the second derives a fresh name, and only one import lands:

maybeAddImport(this, {module: 'm', member: 'default', preferredName: 'Theming'});  // 'Theming'
maybeAddImport(this, {module: 'm', member: 'default', preferredName: 'Th'});       // 'Th'  ← nothing binds this
// emitted: import Theming from 'm';

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; alias stays, 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 aliasName is undefined:

maybeAddImport(this, {module: 'fs', member: 'readFile', alias: 'readFile', onlyIfReferenced: false});
// before                            // after
import {readFile} from 'fs';         import {readFile, readFile} from 'fs';
readFile('x');                       readFile('x');

Both now compare the bound name: (aliasName ?? importName) === this.bindingName.

Tests

Fourteen under describe('bound name') in add-import.test.ts, seven in template-bindings.test.ts, each pinning a distinct line, one per bug above. The templating six were mutation-checked: breaking namesItsParent kills 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 — ChangeImport opts out by pinning, and a caller that removes through maybeRemoveImport puts 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, so maybeAddImport({module: '@scope/pkg'}) emits an unusable import — unchanged here, and closed by passing alias or preferredName.

`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.
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.
@github-project-automation github-project-automation Bot moved this from In Progress to Ready to Review in OpenRewrite 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
knutwannheden marked this pull request as draft August 27, 2026 14:52
@knutwannheden
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
jkschneider merged commit cd8aecb into main Aug 27, 2026
1 check passed
@jkschneider
jkschneider deleted the maybeaddimport-returns-the-name-it-bound branch August 27, 2026 18:44
@github-project-automation github-project-automation Bot moved this from Ready to Review 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.

3 participants