Skip to content

JavaScript: bindModule gives a recipe a local binding on either module system - #8685

Draft
knutwannheden wants to merge 44 commits into
mainfrom
bindmodule-one-api-for-amd-and-esm-bindings
Draft

JavaScript: bindModule gives a recipe a local binding on either module system#8685
knutwannheden wants to merge 44 commits into
mainfrom
bindmodule-one-api-for-amd-and-esm-bindings

Conversation

@knutwannheden

@knutwannheden knutwannheden commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

recipes-ui5 carries ~600 lines in src/amd.ts doing for sap.ui.define what maybeAddImport does for ES modules, and its MigrateDeprecatedUi5Api — 210 of the UI5 linter's 217 curated fixes — no-ops entirely on ESM sources. Adding an ESM lane there meant a second implementation of one idea in one module, plus the framework's.

The operation those recipes want is not "add an import". It is "give me a local binding for module M, creating one if needed" — and an AMD caller cannot supply the name, because the module may already be bound under another name, the conventional name may be taken, or binding may be impossible. AMD pairs the dependency array with the factory parameter list by position, and a parameter can only be appended at the end, so a block declaring a different number of dependencies than parameters cannot be extended without binding the new module against the wrong one.

const element = await bindModule(this, "sap/ui/core/Element");
if (element === undefined) return call;
return template`${raw(element)}.getElementById(${id})`.apply(call, this.cursor, {values: match});

That recipe is unchanged between the two module systems:

sap.ui.define(["sap/m/Button"], function (Button) { sap.ui.getCore().byId("p"); });
sap.ui.define(["sap/m/Button", "sap/ui/core/Element"], function (Button, Element) { Element.getElementById("p"); });
import Button from "sap/m/Button";                 import Button from "sap/m/Button";
                                                   import Element from "sap/ui/core/Element";
sap.ui.getCore().byId("p");                        Element.getElementById("p");

Alongside it: moduleBindings reports what a file binds and which lane it uses, isAmdBlock answers the node-scoped question, maybeRemoveImport gains an AMD lane, and removeNewlyUnusedAmdBindings drops dependencies a rewrite orphaned.

Why not a style flag on maybeAddImport

member and typeOnly are unsatisfiable where a factory parameter is the only binding form; the AMD lane can refuse and the ESM lane cannot; and answering costs a scan of cu.statements on one lane and a traversal of the factory body on the other, so one is synchronous and one cannot be. A flag would gate half the options on its own value. bindModule decides everything synchronously from the cursor and defers only the edit, so the execution model stays the one maybeAddImport established. Reach for it where a file might be AMD; a recipe that only meets ES modules should keep calling maybeAddImport, whose ESM behaviour is what bindModule's ESM lane delegates to.

What the positional pairing costs

Every edit keeps both lists index-aligned, because nothing in the LST enforces it and a mistake parses, prints plausibly, and binds later modules to wrong names. Review found several of exactly that shape, each verified by parsing, editing and printing rather than by reading: an arrow factory's trailing comma is an extra J.Empty rather than a TrailingComma marker, so the two factory forms disagree and appending produced (B,,D); a one-parameter arrow needs parenthesized set or prints B , D=> {}; a factory declaring fewer parameters than the block has dependencies took an appended parameter at the wrong index; and a destructuring declaration's names never reached the deconfliction pool, so const {Element} = window in a body emitted a duplicate binding.

Tests

test/javascript/amd.test.ts pins the mechanics against a hand-driven visitor, test/javascript/bind-module.test.ts pins the API — 58 between them, one per decision. The four deconfliction tests and the two refusal tests were kept separate because each dies to a different mutation; where two died to the same one-line revert they were merged. declaredNames' four branches — identifier, object pattern, nesting, rest element — are each mutation-verified.

Scope

add-import.ts changes by one word, exporting moduleNameOf. No AMD analogue of a side-effect import yet, so recipes-ui5 keeps withUnboundDependency; the removal sweep is AMD-only, so an orphaned import stays; and amdCallee does not reach maybeRemoveImport, whose signature takes no options. moduleSystem reports "none" for a file with no module syntax, because bindModule binding into a plain script would turn it into a module — the caller decides, since adding a first import to a file meant to be a module is ordinary and the two are indistinguishable.

Design is recorded in doc/adr/0013-javascript-module-bindings.md.

Downstream

Ported across five recipes-ui5 recipes: net −251 source lines, 252 tests green. MigrateDeprecatedUi5Api loses modulesNeeded, its bindings map and one of three visitors, dropping to a single traversal, and starts working on ESM sources with no ESM-specific code. Porting also removed two latent false-negative findings caused by the pre-scan those recipes used: no-globals reported every ES-module binding in a file without a sap.ui.define as an undeclared global, because a pre-scan that finds no block is indistinguishable from a block that binds nothing.

…rays

Pins amdBlockOf's dotted-callee branch (namespaceOf), documents and tests
the bare-callee-matches-any-receiver breadth, and adds a test that parses
a literal empty dependency array instead of only exercising the synthetic
noDependencies() stand-in.
An arrow function's parameter list is shaped unlike every other comma-separated
list in the tree: a trailing comma is an extra J.Empty entry rather than a
TrailingComma marker, and a parameter's separating whitespace sits on the
identifier inside its declaration. Appending against that shape printed
`(B,,D)` and put every later parameter at the wrong index, silently binding
each module to the name before it.

normalizeArrowParameters folds both into the shape the rest of the module
already handles. Growing a lambda past one parameter now also sets
parenthesized, since `B => {}` otherwise prints `B , D=> {}`, and moves the
space that sat before the arrow so it does not end up before the closing paren.

withDependency refuses a block whose factory declares fewer parameters than the
block has dependencies: both lists are appended at the end, so unequal lengths
put the new dependency and its parameter at different indices. removeEntry
returns unchanged for an index outside the list rather than reading past it.

A comment on the trailing dependency stays with the entry it followed.
…reference

Restores the unreferenced-binding guard on AddAmdDependency and fixes the
test fixtures that had masked it: the rebind() helper recorded the name
bindModule returned but never wrote it into the tree, which is exactly the
abandon case the gate exists to drop. rebind() now emits <binding>.target()
so the gate's positive case is real, and a new askAndAbandon()-based test
pins the negative case.
… block

Restores the ?? visited fallback dropped in the previous fix round:
withDependency's refusal (parameter gap re-checked against the final tree)
was deleting the whole define call instead of leaving it unchanged when
another edit in the same visit dropped a factory parameter.

bindAmd also now answers a second bindModule call for the same module at
the same block from the queued reservation instead of double-adding the
dependency, matching the rule ADR 0013 states for both lanes and the way
maybeAddImport already answers repeat ESM requests.

Adds tests for both regressions plus the previously-uncovered queue-as-
deconfliction-source case and the AddAmdDependency block-not-found throw,
each verified by reverting the corresponding fix and confirming the test
fails for the stated reason.
…ncy count mismatch, not just a gap

withDependency and bindAmd's pre-check only guarded params < deps, so a factory with a surplus
parameter got misaligned by an addition instead: the new dependency paired with the wrong
parameter and the caller's emitted reference to the intended one became a runtime TypeError.
Both now refuse on inequality in either direction, matching recipes-ui5's amd.ts.

Also unifies the AMD factory body-reference scan (previously duplicated three times with two
undeclared behavioural deltas) into one shared bodyOf/references pair in bind-module.ts,
parameterized on what a body-less factory answers.
…ismatch fix quietly repaired

Unifying bodyOf across bind-module.ts and remove-amd-dependency.ts widened it beyond J.Block,
which also fixed a latent defect: bindModule could never append a dependency to an
expression-bodied arrow factory (`(B) => ...`) — AddAmdDependency's references() saw no body
and silently no-opped after already marking itself applied, leaving the caller's emitted
reference bound to nothing. Adds a regression test through the real bindModule pipeline and
rewords a comment left describing the pre-fix parameter-gap-only guard.
… a multi-removal test

Fix round 1: usedBindings and the sweep loop were calling references()
once per parameter, walking the factory body O(dependencies) or
O(removals x dependencies) times. AMD blocks routinely carry 15-30
dependencies, so this cost 10-48x on realistic bodies. namesUsed()
walks the body once into a Set instead; the sweep loop's per-removal
usedNow is hoisted out since withoutDependencyAt never touches the
body. references() itself is untouched for its single-name callers.

Also adds a test with four dependencies and two non-adjacent removals,
the only case in the suite exercising more than one removal per block.
- declaredNames now reads object and array destructuring patterns
  (const {Element} = window), not just plain identifiers, so bindModule
  no longer emits a name that collides with a destructured local.
- requiredModule requires an absent call.select, so `obj.require(...)`
  no longer misreads as a CommonJS module binding and disables the
  whole file's ESM lane.
- isCommonJs refuses on a .cjs sourcePath even with no require call
  yet, matching add-import.ts's own extension handling.
- removeEntry's leading-prefix merge on removing index 0 now spreads
  the survivor's own prefix instead of overwriting it outright, so a
  comment on the removed entry no longer relabels the survivor, and a
  comment already on the survivor is no longer silently dropped.
- AddAmdDependency now raises when withDependency finds a reopened
  count mismatch, instead of silently emitting an unbound reference —
  matching how the method already handles the sibling missing-block
  case, since the caller has already emitted the reference either way.
- removeNewlyUnusedBindings renamed to removeNewlyUnusedAmdBindings:
  it only ever acted on the AMD lane.
- index.ts replaces `export *` from ./amd and ./bind-module with an
  explicit list: the ADR's declared surface plus the AMD mechanics
  consumers build on directly, dropping ~9 module-internal names
  (cursorOf, enclosingAmdBlock, calleesOf, etc.) that had no consumer
  outside their own file.
- moduleBindings/isAmdBlock narrowed to Pick<BindModuleOptions,
  "amdCallee">, and a stale comment in remove-import.ts corrected.

Each of the three most failure-prone fixes (declaredNames, the
selected-require guard, and removeEntry's comment handling) has a new
test that was verified to fail for the stated reason when the fix was
reverted.
bindingNames already unwrapped JS.BindingElement on both pattern forms
and recursed into nested patterns, but nothing pinned that: the only
existing test used a flat object pattern, whose BindingElement.name is
always a plain identifier. A "forgot to recurse into a nested pattern"
mutation passes that test silently and only fails on an array pattern
whose element is itself an object pattern (const [{Deep}] = window).

Verified the distinction by mutation: reverting the recursive unwrap
entirely breaks both tests with the reused-name (collision) output;
reverting only the recursive step (handle one level, stop) leaves the
flat test green and fails only the new nested one.
… dead comment split

bindingNames fell through both its ObjectBindingPattern/ArrayBindingPattern
checks for a rest element (const [...Element] = window or
const {a, ...Element} = window), since a BindingElement's name is a
JS.Spread there rather than an identifier or nested pattern — so it
returned no names and bindModule could still pick a colliding one.
Added a branch that recurses into the Spread's own expression.

Also collapsed splitLeadingComments into leadingWhitespaceOf: the
{comments, whitespace} pair only ever had its whitespace half read at
the one call site in removeEntry, unlike its sibling
splitTrailingComments, which genuinely uses both halves.

Mutation-tested the rest-element fix the same way as the other
declaredNames branches: reverting it reproduces the exact reused-name
collision output, confirmed and restored.
A bare script with no import, export, require binding, or enclosing
AMD block was indistinguishable from an ES module that simply has no
imports: moduleBindings reported "esm" either way. bindModule then had
no way to refuse turning a plain script into a module, changing its
load semantics (strict mode, scoping, load order) on a file that was
never meant to become one.

Added "none" to ModuleBindings["moduleSystem"], detected by a new
hasEsmSyntax check: an import, an export statement (export {a,b},
export default, export * from), or export as a modifier on a class,
function, or variable declaration (export class/function/const parse
as a modifier on the declaration itself rather than a wrapper
statement, confirmed by parsing each form and inspecting the tree).
bindModule's own behavior is unchanged - "none" isn't "commonjs", so
it still falls through to maybeAddImport; a caller that must not
convert a plain script checks moduleBindings(this).moduleSystem itself.

Updated the existing bare-script test to expect "none" instead of
"esm", added a test pinning that an export-only file (no import) still
reads as "esm" - which only passes with the modifier check, not the
statement-kind check alone - and corrected the selected-require test's
expectation, since that fixture also has no actual module syntax.
Mutation-tested the "none" branch: reverting it reproduces the old
default-to-"esm" behavior, confirmed and restored.
@knutwannheden

Copy link
Copy Markdown
Contributor Author

I am still checking how we can unify this with maybeAddImport. I wasn't even aware of these AMD module dependencies until yesterday.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: In Progress

Development

Successfully merging this pull request may close these issues.

1 participant