JavaScript: bindModule gives a recipe a local binding on either module system - #8685
Draft
knutwannheden wants to merge 44 commits into
Draft
JavaScript: bindModule gives a recipe a local binding on either module system#8685knutwannheden wants to merge 44 commits into
knutwannheden wants to merge 44 commits into
Conversation
…g return cannot make
…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.
… of two questions
…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.
…does not reach removal
…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.
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. |
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.
recipes-ui5carries ~600 lines insrc/amd.tsdoing forsap.ui.definewhatmaybeAddImportdoes for ES modules, and itsMigrateDeprecatedUi5Api— 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.
That recipe is unchanged between the two module systems:
Alongside it:
moduleBindingsreports what a file binds and which lane it uses,isAmdBlockanswers the node-scoped question,maybeRemoveImportgains an AMD lane, andremoveNewlyUnusedAmdBindingsdrops dependencies a rewrite orphaned.Why not a style flag on
maybeAddImportmemberandtypeOnlyare 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 ofcu.statementson 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.bindModuledecides everything synchronously from the cursor and defers only the edit, so the execution model stays the onemaybeAddImportestablished. Reach for it where a file might be AMD; a recipe that only meets ES modules should keep callingmaybeAddImport, whose ESM behaviour is whatbindModule'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.Emptyrather than aTrailingCommamarker, so the two factory forms disagree and appending produced(B,,D); a one-parameter arrow needsparenthesizedset or printsB , 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, soconst {Element} = windowin a body emitted a duplicate binding.Tests
test/javascript/amd.test.tspins the mechanics against a hand-driven visitor,test/javascript/bind-module.test.tspins 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.tschanges by one word, exportingmoduleNameOf. No AMD analogue of a side-effect import yet, sorecipes-ui5keepswithUnboundDependency; the removal sweep is AMD-only, so an orphanedimportstays; andamdCalleedoes not reachmaybeRemoveImport, whose signature takes no options.moduleSystemreports"none"for a file with no module syntax, becausebindModulebinding 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-ui5recipes: net −251 source lines, 252 tests green.MigrateDeprecatedUi5ApilosesmodulesNeeded, 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-globalsreported every ES-module binding in a file without asap.ui.defineas an undeclared global, because a pre-scan that finds no block is indistinguishable from a block that binds nothing.