From 42b54f2cae999fc7e91f14c2a26fff39143d2ca7 Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Thu, 27 Aug 2026 14:55:12 +0200 Subject: [PATCH 01/41] JavaScript: locate an AMD block by node kind --- .../rewrite/src/javascript/amd.ts | 140 ++++++++++++++++++ .../rewrite/test/javascript/amd.test.ts | 43 ++++++ 2 files changed, 183 insertions(+) create mode 100644 rewrite-javascript/rewrite/src/javascript/amd.ts create mode 100644 rewrite-javascript/rewrite/test/javascript/amd.test.ts diff --git a/rewrite-javascript/rewrite/src/javascript/amd.ts b/rewrite-javascript/rewrite/src/javascript/amd.ts new file mode 100644 index 0000000000..c6a5690be7 --- /dev/null +++ b/rewrite-javascript/rewrite/src/javascript/amd.ts @@ -0,0 +1,140 @@ +/* + * Copyright 2025 the original author or authors. + *

+ * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://docs.moderne.io/licensing/moderne-source-available-license + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import {emptyMarkers} from "../markers"; +import {randomId} from "../uuid"; +import {emptyContainer, emptySpace, Expression, isIdentifier, isLiteral, J} from "../java"; +import {JS} from "./tree"; + +/** RequireJS and Dojo write `define`; UI5 namespaces it as `sap.ui.define`. */ +export const DEFAULT_AMD_CALLEES: readonly string[] = ["define", "require"]; + +export interface AmdBlock { + /** -1 where the block is written without one, as `define(factory)`. */ + readonly dependenciesIndex: number; + readonly dependencies: J.NewArray; + readonly factoryIndex: number; + readonly factory: J.MethodDeclaration | J.Lambda; +} + +export function amdBlockOf( + call: J.MethodInvocation, + callees: readonly string[] = DEFAULT_AMD_CALLEES +): AmdBlock | undefined { + if (!isAmdCallee(call, callees)) { + return undefined; + } + const args = call.arguments.elements; + const dependenciesIndex = args.findIndex(arg => arg.element.kind === J.Kind.NewArray); + const factoryIndex = dependenciesIndex >= 0 ? + dependenciesIndex + 1 : + args.findIndex(arg => asFactory(arg.element) !== undefined); + const factory = factoryIndex >= 0 && factoryIndex < args.length ? + asFactory(args[factoryIndex].element) : + undefined; + if (factory === undefined) { + return undefined; + } + const dependencies = dependenciesIndex >= 0 ? + args[dependenciesIndex].element as J.NewArray : + noDependencies(); + return {dependenciesIndex, dependencies, factoryIndex, factory}; +} + +/** Stands in for the array a block written without one would have had. */ +function noDependencies(): J.NewArray { + return { + kind: J.Kind.NewArray, + id: randomId(), + prefix: emptySpace, + markers: emptyMarkers, + dimensions: [], + initializer: emptyContainer() + }; +} + +function isAmdCallee(call: J.MethodInvocation, callees: readonly string[]): boolean { + // The name check first: this runs for every call in a file, and almost none are these. + const simpleName = call.name.simpleName; + return callees.some(callee => { + const last = callee.substring(callee.lastIndexOf(".") + 1); + return simpleName === last && (callee === last || namespaceOf(call) === callee); + }); +} + +/** The dotted path a call is written under, `sap.ui.define` for `sap.ui.define(…)`. */ +function namespaceOf(call: J.MethodInvocation): string | undefined { + const segments: string[] = [call.name.simpleName]; + let node: J | undefined = call.select?.element; + while (node !== undefined) { + if (node.kind === J.Kind.FieldAccess) { + const access = node as J.FieldAccess; + segments.unshift(access.name.element.simpleName); + node = access.target; + } else if (isIdentifier(node)) { + segments.unshift(node.simpleName); + return segments.join("."); + } else { + return undefined; + } + } + return segments.join("."); +} + +/** A factory is written either as a function expression or as an arrow function. */ +function asFactory(argument: J): J.MethodDeclaration | J.Lambda | undefined { + if (argument.kind === JS.Kind.StatementExpression) { + const statement = (argument as JS.StatementExpression).statement; + return statement.kind === J.Kind.MethodDeclaration ? statement as J.MethodDeclaration : undefined; + } + return argument.kind === JS.Kind.ArrowFunction ? (argument as JS.ArrowFunction).lambda : undefined; +} + +/** + * The elements of a `[]` or `()`, which the parser fills with a single `J.Empty` when + * there are none. + */ +export function present(elements: readonly J.RightPadded[]): J.RightPadded[] { + return elements.length === 1 && elements[0].element.kind === J.Kind.Empty ? [] : [...elements]; +} + +export function elementsOf(block: AmdBlock): J.RightPadded[] { + return present(block.dependencies.initializer?.elements ?? []); +} + +export function parametersOf(block: AmdBlock): J.RightPadded[] { + return present(block.factory.kind === J.Kind.MethodDeclaration ? + (block.factory as J.MethodDeclaration).parameters.elements : + (block.factory as J.Lambda).parameters.parameters); +} + +export function dependencyNames(block: AmdBlock): string[] { + return elementsOf(block).map(padded => { + const element = padded.element; + return isLiteral(element) && typeof element.value === "string" ? element.value : ""; + }); +} + +export function parameterNames(block: AmdBlock): (string | undefined)[] { + return parametersOf(block).map(padded => identifierOf(padded.element)?.simpleName); +} + +export function identifierOf(parameter: J): J.Identifier | undefined { + if (parameter.kind === J.Kind.VariableDeclarations) { + const name = (parameter as J.VariableDeclarations).variables[0]?.element.name; + return name !== undefined && isIdentifier(name) ? name : undefined; + } + return isIdentifier(parameter) ? parameter : undefined; +} diff --git a/rewrite-javascript/rewrite/test/javascript/amd.test.ts b/rewrite-javascript/rewrite/test/javascript/amd.test.ts new file mode 100644 index 0000000000..6457daae99 --- /dev/null +++ b/rewrite-javascript/rewrite/test/javascript/amd.test.ts @@ -0,0 +1,43 @@ +import {JavaScriptParser} from "../../src/javascript"; +import {J} from "../../src/java"; +import {JS} from "../../src/javascript"; +import {amdBlockOf, dependencyNames, parameterNames} from "../../src/javascript/amd"; + +async function firstCall(source: string): Promise { + const parser = new JavaScriptParser(); + const gen = parser.parse({text: source, sourcePath: "a.js"}); + const parsed = (await gen.next()).value as JS.CompilationUnit; + const statement = parsed.statements[0].element; + return statement as J.MethodInvocation; +} + +describe("amdBlockOf", () => { + test("a named block with an export flag still pairs deps with parameters", async () => { + const call = await firstCall(`sap.ui.define("my/M", ["a/B", "c/D"], function (B, D) {}, true);`); + const block = amdBlockOf(call)!; + expect(dependencyNames(block)).toEqual(["a/B", "c/D"]); + expect(parameterNames(block)).toEqual(["B", "D"]); + }); + + test("a block written without a dependency array has an empty one", async () => { + const call = await firstCall(`sap.ui.define(function () {});`); + const block = amdBlockOf(call)!; + expect(block.dependenciesIndex).toBe(-1); + expect(dependencyNames(block)).toEqual([]); + }); + + test("an arrow factory is a factory", async () => { + const call = await firstCall(`define(["a/B"], (B) => {});`); + expect(parameterNames(amdBlockOf(call)!)).toEqual(["B"]); + }); + + test("a call that is not an AMD block is not one", async () => { + const call = await firstCall(`foo.bar(["a/B"], function (B) {});`); + expect(amdBlockOf(call)).toBeUndefined(); + }); + + test("a dependency the factory takes no parameter for reads as unbound", async () => { + const call = await firstCall(`define(["a/B", "c/D"], function (B) {});`); + expect(parameterNames(amdBlockOf(call)!)).toEqual(["B"]); + }); +}); From 0e68c3ed70139d698a79e8159114c9c75f6c3123 Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Thu, 27 Aug 2026 15:04:01 +0200 Subject: [PATCH 02/41] JavaScript: cover namespaced-callee matching and literal empty AMD arrays 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. --- .../rewrite/src/javascript/amd.ts | 4 ++++ .../rewrite/test/javascript/amd.test.ts | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/rewrite-javascript/rewrite/src/javascript/amd.ts b/rewrite-javascript/rewrite/src/javascript/amd.ts index c6a5690be7..a257828bae 100644 --- a/rewrite-javascript/rewrite/src/javascript/amd.ts +++ b/rewrite-javascript/rewrite/src/javascript/amd.ts @@ -65,6 +65,10 @@ function noDependencies(): J.NewArray { }; } +/** + * A callee written without a dot (`"define"`) matches by simple name on any receiver, so it + * also matches `foo.define(...)`; a dotted callee (`"sap.ui.define"`) requires the whole path. + */ function isAmdCallee(call: J.MethodInvocation, callees: readonly string[]): boolean { // The name check first: this runs for every call in a file, and almost none are these. const simpleName = call.name.simpleName; diff --git a/rewrite-javascript/rewrite/test/javascript/amd.test.ts b/rewrite-javascript/rewrite/test/javascript/amd.test.ts index 6457daae99..18edd2aa5b 100644 --- a/rewrite-javascript/rewrite/test/javascript/amd.test.ts +++ b/rewrite-javascript/rewrite/test/javascript/amd.test.ts @@ -40,4 +40,23 @@ describe("amdBlockOf", () => { const call = await firstCall(`define(["a/B", "c/D"], function (B) {});`); expect(parameterNames(amdBlockOf(call)!)).toEqual(["B"]); }); + + test("a callee written without a dot matches whatever the receiver", async () => { + const call = await firstCall(`foo.define(["a/B"], function (B) {});`); + expect(amdBlockOf(call)).toBeDefined(); + }); + + test("a dotted callee requires the whole namespaced path", async () => { + const namespaced = await firstCall(`sap.ui.define(["a/B"], function (B) {});`); + expect(amdBlockOf(namespaced, ["sap.ui.define"])).toBeDefined(); + expect(amdBlockOf(namespaced, ["sap.ui.other"])).toBeUndefined(); + + const bare = await firstCall(`define(["a/B"], function (B) {});`); + expect(amdBlockOf(bare, ["sap.ui.define"])).toBeUndefined(); + }); + + test("an empty dependency array parses as no dependencies, not one J.Empty", async () => { + const call = await firstCall(`define([], function () {});`); + expect(dependencyNames(amdBlockOf(call)!)).toEqual([]); + }); }); From 6397d1776810c6e50bf3d2d70d03275aa8706e73 Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Thu, 27 Aug 2026 15:09:18 +0200 Subject: [PATCH 03/41] JavaScript: read module bindings from either an AMD block or ES imports --- .../rewrite/src/javascript/bind-module.ts | 202 ++++++++++++++++++ .../rewrite/src/javascript/index.ts | 2 + .../test/javascript/bind-module.test.ts | 53 +++++ 3 files changed, 257 insertions(+) create mode 100644 rewrite-javascript/rewrite/src/javascript/bind-module.ts create mode 100644 rewrite-javascript/rewrite/test/javascript/bind-module.test.ts diff --git a/rewrite-javascript/rewrite/src/javascript/bind-module.ts b/rewrite-javascript/rewrite/src/javascript/bind-module.ts new file mode 100644 index 0000000000..b5a34d392e --- /dev/null +++ b/rewrite-javascript/rewrite/src/javascript/bind-module.ts @@ -0,0 +1,202 @@ +/* + * Copyright 2025 the original author or authors. + *

+ * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://docs.moderne.io/licensing/moderne-source-available-license + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import {J} from "../java"; +import {JS} from "./tree"; +import {Cursor} from "../tree"; +import {JavaScriptVisitor} from "./visitor"; +import {QuoteChar} from "./add-import"; +import {AmdBlock, amdBlockOf, DEFAULT_AMD_CALLEES, dependencyNames, parameterNames, present} from "./amd"; + +export type EsmBindingForm = "default" | "namespace"; + +export interface BindModuleOptions { + /** + * Preferred local name, defaulting to the module's last path segment. A preference: an + * existing binding or a name already in scope overrides it. + */ + binding?: string; + + /** The form to create on the ESM lane. Ignored on the AMD lane. */ + esmForm?: EsmBindingForm; + + /** Quote character for a created ESM specifier, defaulting to the file's own. */ + quoteStyle?: QuoteChar; + + /** Callees that introduce an AMD block. */ + amdCallee?: string | readonly string[]; +} + +export interface ModuleBindings { + /** The module `localName` refers to, or undefined when it is not a module binding. */ + moduleOf(localName: string): string | undefined; + + /** The local name bound to `module`, or undefined when nothing binds it. */ + bindingOf(module: string): string | undefined; + + /** The lane these bindings come from, and the one `bindModule` would use. */ + readonly moduleSystem: "esm" | "amd" | "commonjs"; +} + +export function calleesOf(options?: BindModuleOptions): readonly string[] { + const callee = options?.amdCallee; + return callee === undefined ? DEFAULT_AMD_CALLEES : typeof callee === "string" ? [callee] : callee; +} + +export function isAmdBlock(node: J, options?: BindModuleOptions): boolean { + return node.kind === J.Kind.MethodInvocation && + amdBlockOf(node as J.MethodInvocation, calleesOf(options)) !== undefined; +} + +/** `cursor` is protected on `TreeVisitor` and this API is free functions, so reaching it takes a cast. */ +export function cursorOf(visitor: JavaScriptVisitor): Cursor | undefined { + return (visitor as unknown as {cursor?: Cursor}).cursor; +} + +/** The nearest AMD block the cursor sits inside, which is the one a binding belongs to. */ +export function enclosingAmdBlock( + visitor: JavaScriptVisitor, + options?: BindModuleOptions +): {call: J.MethodInvocation, block: AmdBlock} | undefined { + const callees = calleesOf(options); + let cursor = cursorOf(visitor); + while (cursor !== undefined) { + const value = cursor.value as J | undefined; + if (value?.kind === J.Kind.MethodInvocation) { + const block = amdBlockOf(value as J.MethodInvocation, callees); + if (block !== undefined) { + return {call: value as J.MethodInvocation, block}; + } + } + cursor = cursor.parent; + } + return undefined; +} + +export function compilationUnitOf(visitor: JavaScriptVisitor): JS.CompilationUnit | undefined { + return cursorOf(visitor)?.firstEnclosing( + (v): v is JS.CompilationUnit => v?.kind === JS.Kind.CompilationUnit); +} + +export function moduleBindings( + visitor: JavaScriptVisitor, + options?: BindModuleOptions +): ModuleBindings { + const amd = enclosingAmdBlock(visitor, options); + if (amd !== undefined) { + const modules = dependencyNames(amd.block); + const bindings = parameterNames(amd.block); + return { + moduleSystem: "amd", + moduleOf: localName => { + const index = bindings.indexOf(localName); + return index < 0 ? undefined : modules[index]; + }, + bindingOf: module => { + const index = modules.indexOf(module); + return index < 0 ? undefined : bindings[index]; + } + }; + } + + const cu = compilationUnitOf(visitor); + const bound = cu === undefined ? [] : moduleObjectBindings(cu); + return { + moduleSystem: cu !== undefined && isCommonJs(cu) ? "commonjs" : "esm", + moduleOf: localName => bound.find(b => b.name === localName)?.module, + bindingOf: module => bound.find(b => b.module === module)?.name + }; +} + +interface ModuleObjectBinding { + name: string; + module: string; +} + +/** Whether the file binds its modules with `require`, which decides whether a create is possible. */ +function isCommonJs(cu: JS.CompilationUnit): boolean { + let requires = false; + for (const stmt of cu.statements) { + if (stmt.element?.kind === JS.Kind.Import) { + return false; + } + if (requiredModule(stmt.element) !== undefined) { + requires = true; + } + } + return requires; +} + +/** The module a top-level `const X = require("m")` names, for the one variable it declares. */ +function requiredModule(statement: J | undefined): string | undefined { + if (statement?.kind !== J.Kind.VariableDeclarations) { + return undefined; + } + const variables = (statement as J.VariableDeclarations).variables; + const initializer = variables.length === 1 ? variables[0].element?.initializer?.element : undefined; + if (initializer?.kind !== J.Kind.MethodInvocation) { + return undefined; + } + const call = initializer as J.MethodInvocation; + if (call.name.simpleName !== "require") { + return undefined; + } + const argument = present(call.arguments.elements)[0]?.element; + return argument?.kind === J.Kind.Literal && typeof (argument as J.Literal).value === "string" + ? (argument as J.Literal).value as string + : undefined; +} + +/** Only whole-module bindings: a named member does not name the module object. */ +function moduleObjectBindings(cu: JS.CompilationUnit): ModuleObjectBinding[] { + const bindings: ModuleObjectBinding[] = []; + for (const stmt of cu.statements) { + const statement = stmt.element; + if (statement?.kind !== JS.Kind.Import) { + continue; + } + const jsImport = statement as JS.Import; + const specifier = jsImport.moduleSpecifier?.element; + if (specifier?.kind !== J.Kind.Literal) { + continue; + } + const module = (specifier as J.Literal).value; + if (typeof module !== "string") { + continue; + } + const clause = jsImport.importClause; + if (clause?.name?.element?.kind === J.Kind.Identifier) { + bindings.push({name: (clause.name.element as J.Identifier).simpleName, module}); + } + // `namedBindings` is a `JS.Alias` only for `import * as X from "m"`; a renamed + // named import (`{a as b}`) nests its alias inside `NamedImports` instead. + const named = clause?.namedBindings; + if (named?.kind === JS.Kind.Alias) { + const alias = (named as JS.Alias).alias; + if (alias?.kind === J.Kind.Identifier) { + bindings.push({name: (alias as J.Identifier).simpleName, module}); + } + } + } + for (const stmt of cu.statements) { + const module = requiredModule(stmt.element); + const name = module === undefined ? undefined : + (stmt.element as J.VariableDeclarations).variables[0]?.element?.name; + if (module !== undefined && name?.kind === J.Kind.Identifier) { + bindings.push({name: (name as J.Identifier).simpleName, module}); + } + } + return bindings; +} diff --git a/rewrite-javascript/rewrite/src/javascript/index.ts b/rewrite-javascript/rewrite/src/javascript/index.ts index 7e0ddc986f..0b730a0ac9 100644 --- a/rewrite-javascript/rewrite/src/javascript/index.ts +++ b/rewrite-javascript/rewrite/src/javascript/index.ts @@ -33,6 +33,8 @@ export * from "./project-parser"; export * from "./scope"; export * from "./add-import"; +export * from "./amd"; +export * from "./bind-module"; export * from "./remove-import"; export * from "./cleanup/index"; export * from "./recipes/index"; diff --git a/rewrite-javascript/rewrite/test/javascript/bind-module.test.ts b/rewrite-javascript/rewrite/test/javascript/bind-module.test.ts new file mode 100644 index 0000000000..424a3ece82 --- /dev/null +++ b/rewrite-javascript/rewrite/test/javascript/bind-module.test.ts @@ -0,0 +1,53 @@ +import {fromVisitor, RecipeSpec} from "../../src/test"; +import {JavaScriptVisitor, JS, javascript, typescript, moduleBindings, isAmdBlock} from "../../src/javascript"; +import {J} from "../../src/java"; + +function captureBindings(seen: {moduleSystem?: string, module?: string, binding?: string}) { + return new class extends JavaScriptVisitor { + override async visitJsCompilationUnit(cu: JS.CompilationUnit, p: any): Promise { + const bindings = moduleBindings(this); + seen.moduleSystem = bindings.moduleSystem; + seen.module = bindings.moduleOf("Button"); + seen.binding = bindings.bindingOf("sap/m/Button"); + return super.visitJsCompilationUnit(cu, p); + } + }; +} + +describe("moduleBindings", () => { + test("an ESM default import binds its module", async () => { + const spec = new RecipeSpec(); + const seen: {moduleSystem?: string, module?: string, binding?: string} = {}; + spec.recipe = fromVisitor(captureBindings(seen)); + await spec.rewriteRun(typescript(`import Button from "sap/m/Button";`)); + expect(seen).toEqual({moduleSystem: "esm", module: "sap/m/Button", binding: "Button"}); + }); + + test("a file with no bindings reports the lane bindModule would take", async () => { + const spec = new RecipeSpec(); + const seen: {moduleSystem?: string} = {}; + spec.recipe = fromVisitor(captureBindings(seen)); + await spec.rewriteRun(typescript(`const x = 1;`)); + expect(seen.moduleSystem).toBe("esm"); + }); +}); + +describe("isAmdBlock", () => { + test("a define block inside an otherwise-ESM file is still a block", async () => { + const spec = new RecipeSpec(); + const blocks: string[] = []; + spec.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitMethodInvocation(m: J.MethodInvocation, p: any): Promise { + if (isAmdBlock(m)) { + blocks.push(m.name.simpleName); + } + return super.visitMethodInvocation(m, p); + } + }); + await spec.rewriteRun(javascript(` + import x from "m"; + sap.ui.define(["a/B"], function (B) {}); + `)); + expect(blocks).toEqual(["define"]); + }); +}); From ae09ac92321909bbf60381210e6e7a10098425a9 Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Thu, 27 Aug 2026 15:18:43 +0200 Subject: [PATCH 04/41] JavaScript: cover moduleBindings' AMD lane, member exclusion, and commonjs arm --- .../test/javascript/bind-module.test.ts | 80 ++++++++++++++++++- 1 file changed, 76 insertions(+), 4 deletions(-) diff --git a/rewrite-javascript/rewrite/test/javascript/bind-module.test.ts b/rewrite-javascript/rewrite/test/javascript/bind-module.test.ts index 424a3ece82..cfe16f69fe 100644 --- a/rewrite-javascript/rewrite/test/javascript/bind-module.test.ts +++ b/rewrite-javascript/rewrite/test/javascript/bind-module.test.ts @@ -1,14 +1,27 @@ import {fromVisitor, RecipeSpec} from "../../src/test"; -import {JavaScriptVisitor, JS, javascript, typescript, moduleBindings, isAmdBlock} from "../../src/javascript"; +import { + JavaScriptVisitor, JS, javascript, typescript, moduleBindings, isAmdBlock, ModuleBindings +} from "../../src/javascript"; import {J} from "../../src/java"; -function captureBindings(seen: {moduleSystem?: string, module?: string, binding?: string}) { +function captureBindings(seen: {moduleSystem?: string, module?: string, binding?: string}, + localName: string = "Button", moduleName: string = "sap/m/Button") { return new class extends JavaScriptVisitor { override async visitJsCompilationUnit(cu: JS.CompilationUnit, p: any): Promise { const bindings = moduleBindings(this); seen.moduleSystem = bindings.moduleSystem; - seen.module = bindings.moduleOf("Button"); - seen.binding = bindings.bindingOf("sap/m/Button"); + seen.module = bindings.moduleOf(localName); + seen.binding = bindings.bindingOf(moduleName); + return super.visitJsCompilationUnit(cu, p); + } + }; +} + +/** Runs `extract` with the file's `ModuleBindings`, visited from the compilation unit itself. */ +function captureModuleBindings(extract: (bindings: ModuleBindings) => void) { + return new class extends JavaScriptVisitor { + override async visitJsCompilationUnit(cu: JS.CompilationUnit, p: any): Promise { + extract(moduleBindings(this)); return super.visitJsCompilationUnit(cu, p); } }; @@ -30,6 +43,65 @@ describe("moduleBindings", () => { await spec.rewriteRun(typescript(`const x = 1;`)); expect(seen.moduleSystem).toBe("esm"); }); + + test("an AMD block's parameters bind positionally to its dependencies", async () => { + const spec = new RecipeSpec(); + const seen: {moduleSystem?: string, module?: string, binding?: string} = {}; + spec.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitReturn(ret: J.Return, p: any): Promise { + const bindings = moduleBindings(this); + seen.moduleSystem = bindings.moduleSystem; + seen.module = bindings.moduleOf("Button"); + seen.binding = bindings.bindingOf("sap/m/Button"); + return super.visitReturn(ret, p); + } + }); + await spec.rewriteRun(javascript(` + sap.ui.define(["sap/m/Button"], function (Button) { + return Button; + }); + `)); + expect(seen).toEqual({moduleSystem: "amd", module: "sap/m/Button", binding: "Button"}); + }); + + test("a named ESM import does not bind the module object", async () => { + const spec = new RecipeSpec(); + const seen: {module?: string, binding?: string} = {}; + spec.recipe = fromVisitor(captureModuleBindings(bindings => { + seen.module = bindings.moduleOf("getElementById"); + seen.binding = bindings.bindingOf("sap/ui/core/Element"); + })); + await spec.rewriteRun(typescript(`import {getElementById} from "sap/ui/core/Element";`)); + expect(seen).toEqual({module: undefined, binding: undefined}); + }); + + test("an ESM namespace import binds its module", async () => { + const spec = new RecipeSpec(); + const seen: {binding?: string} = {}; + spec.recipe = fromVisitor(captureModuleBindings(bindings => { + seen.binding = bindings.bindingOf("sap/ui/core/Element"); + })); + await spec.rewriteRun(typescript(`import * as Element from "sap/ui/core/Element";`)); + expect(seen.binding).toBe("Element"); + }); + + test("require binds the module object but not a destructured member", async () => { + const spec = new RecipeSpec(); + const seen: {moduleSystem?: string, elementBinding?: string, pathBinding?: string} = {}; + spec.recipe = fromVisitor(captureModuleBindings(bindings => { + seen.moduleSystem = bindings.moduleSystem; + seen.elementBinding = bindings.bindingOf("sap/ui/core/Element"); + seen.pathBinding = bindings.bindingOf("path"); + })); + await spec.rewriteRun(javascript(` + const Elem = require("sap/ui/core/Element"); + const {join} = require("path"); + `)); + expect(seen.moduleSystem).toBe("commonjs"); + expect(seen.elementBinding).toBe("Elem"); + + expect(seen.pathBinding).toBeUndefined(); + }); }); describe("isAmdBlock", () => { From 178e3433c20f02b28e4b89b2833fd4f6e0a63e65 Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Thu, 27 Aug 2026 15:24:53 +0200 Subject: [PATCH 05/41] JavaScript: add and remove AMD dependencies index-aligned with the factory --- .../rewrite/src/javascript/amd.ts | 254 ++++++++++++++++++ .../rewrite/test/javascript/amd.test.ts | 75 +++++- 2 files changed, 328 insertions(+), 1 deletion(-) diff --git a/rewrite-javascript/rewrite/src/javascript/amd.ts b/rewrite-javascript/rewrite/src/javascript/amd.ts index a257828bae..187f175997 100644 --- a/rewrite-javascript/rewrite/src/javascript/amd.ts +++ b/rewrite-javascript/rewrite/src/javascript/amd.ts @@ -142,3 +142,257 @@ export function identifierOf(parameter: J): J.Identifier | undefined { } return isIdentifier(parameter) ? parameter : undefined; } + +import {findMarker, Markers} from "../markers"; +import {rightPadded, space, spaceContainsNewline, Statement, TrailingComma} from "../java"; + +/** + * Where an entry's leading whitespace sits. A dependency string carries it directly, while + * a factory parameter carries it on the identifier inside the declaration that wraps it. + */ +interface Slot { + prefixOf(element: T): J.Space; + + withPrefix(element: T, prefix: J.Space): T; +} + +const dependencySlot: Slot = { + prefixOf: element => element.prefix, + withPrefix: (element, prefix) => ({...element, prefix}) +}; + +const parameterSlot: Slot = { + prefixOf: element => identifierOf(element)?.prefix ?? element.prefix, + withPrefix: (element, prefix) => { + if (element.kind !== J.Kind.VariableDeclarations) { + return {...element, prefix}; + } + const declaration = element as J.VariableDeclarations; + const variable = declaration.variables[0]; + return { + ...declaration, + variables: [ + {...variable, element: {...variable.element, name: {...variable.element.name, prefix}}}, + ...declaration.variables.slice(1) + ] + }; + } +}; + +/** + * A trailing comma is a marker on the entry it follows, so it has to travel to whichever + * entry ends up last. Left where it was, it prints as a second comma and the array gains a + * hole, which shifts every dependency after it out of step with its parameter. + */ +function moveTrailingComma( + from: J.RightPadded, + to: J.RightPadded +): {from: J.RightPadded, to: J.RightPadded} { + const trailing = findMarker(from, J.Markers.TrailingComma); + if (trailing === undefined) { + return {from, to}; + } + return { + from: {...from, markers: {...from.markers, markers: from.markers.markers.filter(m => m !== trailing)}}, + to: {...to, markers: {...to.markers, markers: [...to.markers.markers, trailing]}} + }; +} + +/** + * The whitespace that separates one entry from the next, taken from the entries already + * there so that a block listing its dependencies one per line keeps doing so. + */ +function separator(entries: readonly J.RightPadded[], slot: Slot): J.Space { + if (entries.length >= 2) { + return slot.prefixOf(entries[1].element); + } + const first = slot.prefixOf(entries[0].element); + return spaceContainsNewline(first) ? first : space(" "); +} + +/** + * Appends `element`. The trailing entry's padding holds the whitespace before the closing + * bracket, so it has to move along with the position. + */ +function appendEntry( + entries: readonly J.RightPadded[], + element: T, + slot: Slot +): J.RightPadded[] { + const last = entries[entries.length - 1]; + if (last === undefined) { + return [rightPadded(element, emptySpace)]; + } + const positioned = slot.withPrefix(element, separator(entries, slot)); + const moved = moveTrailingComma({...last, after: emptySpace}, rightPadded(positioned, last.after)); + return [...entries.slice(0, -1), moved.from, moved.to]; +} + +/** + * Removes the entry at `index`. The first entry carries no separating whitespace and the + * last carries the whitespace before the closing bracket, so removing either hands its + * padding to whichever entry takes its place. + */ +function removeEntry( + entries: readonly J.RightPadded[], + index: number, + slot: Slot +): J.RightPadded[] { + const remaining = [...entries]; + const [removed] = remaining.splice(index, 1); + if (remaining.length === 0) { + return remaining; + } + if (index === 0) { + remaining[0] = {...remaining[0], element: slot.withPrefix(remaining[0].element, slot.prefixOf(removed.element))}; + } else if (index === remaining.length) { + const last = {...remaining[index - 1], after: removed.after}; + remaining[index - 1] = moveTrailingComma(removed, last).to; + } + return remaining; +} + +function emptyExpression(): J.Empty { + return {kind: J.Kind.Empty, id: randomId(), prefix: emptySpace, markers: emptyMarkers}; +} + +function withElements(dependencies: J.NewArray, elements: J.RightPadded[]): J.NewArray { + const initializer = dependencies.initializer!; + return { + ...dependencies, + initializer: { + ...initializer, + elements: elements.length === 0 ? [rightPadded(emptyExpression(), emptySpace)] : elements + } + }; +} + +function withParameters( + factory: J.MethodDeclaration | J.Lambda, + parameters: J.RightPadded[] +): J.MethodDeclaration | J.Lambda { + const elements = parameters.length === 0 ? [rightPadded(emptyExpression(), emptySpace)] : parameters; + if (factory.kind === J.Kind.MethodDeclaration) { + const method = factory as J.MethodDeclaration; + return {...method, parameters: {...method.parameters, elements: elements as J.RightPadded[]}}; + } + const lambda = factory as J.Lambda; + return {...lambda, parameters: {...lambda.parameters, parameters: elements}}; +} + +function identifier(name: string): J.Identifier { + return { + kind: J.Kind.Identifier, + id: randomId(), + prefix: emptySpace, + markers: emptyMarkers, + annotations: [], + simpleName: name, + type: undefined, + fieldType: undefined + }; +} + +function dependencyLiteral(module: string, quote: string): J.Literal { + return { + kind: J.Kind.Literal, + id: randomId(), + prefix: emptySpace, + markers: emptyMarkers, + value: module, + valueSource: `${quote}${module}${quote}` + }; +} + +/** The quote the block's own dependencies use, so a new one matches them. */ +function quoteOf(block: AmdBlock): string { + for (const padded of elementsOf(block)) { + const source = isLiteral(padded.element) ? (padded.element as J.Literal).valueSource : undefined; + const quote = source?.charAt(0); + if (quote === '"' || quote === "'") { + return quote; + } + } + return '"'; +} + +function parameterDeclaration(name: string): J.VariableDeclarations { + return { + kind: J.Kind.VariableDeclarations, + id: randomId(), + prefix: emptySpace, + markers: emptyMarkers, + leadingAnnotations: [], + modifiers: [], + variables: [rightPadded({ + kind: J.Kind.NamedVariable, + id: randomId(), + prefix: emptySpace, + markers: emptyMarkers, + name: identifier(name), + dimensionsAfterName: [] + } as J.VariableDeclarations.NamedVariable, emptySpace)] + }; +} + +export function withDependency( + call: J.MethodInvocation, + block: AmdBlock, + module: string, + binding: string +): J.MethodInvocation { + const dependencies = withElements( + block.dependencies, + appendEntry(elementsOf(block), dependencyLiteral(module, quoteOf(block)), dependencySlot)); + const factory = withParameters( + block.factory, + appendEntry(parametersOf(block), parameterDeclaration(binding), parameterSlot)); + return withParts(call, block, dependencies, factory); +} + +export function withoutDependencyAt( + call: J.MethodInvocation, + block: AmdBlock, + index: number +): J.MethodInvocation { + const dependencies = withElements(block.dependencies, removeEntry(elementsOf(block), index, dependencySlot)); + const parameters = parametersOf(block); + const factory = index >= parameters.length ? + block.factory : + withParameters(block.factory, removeEntry(parameters, index, parameterSlot)); + return withParts(call, block, dependencies, factory); +} + +function withParts( + call: J.MethodInvocation, + block: AmdBlock, + dependencies: J.NewArray, + factory: J.MethodDeclaration | J.Lambda +): J.MethodInvocation { + const args = [...call.arguments.elements]; + let factoryIndex = block.factoryIndex; + if (block.dependenciesIndex >= 0) { + args[block.dependenciesIndex] = {...args[block.dependenciesIndex], element: dependencies}; + } else if (present(dependencies.initializer?.elements ?? []).length > 0) { + // The array takes the position the factory held, so it inherits what led up to it. + const leading = args[factoryIndex].element; + args[factoryIndex] = {...args[factoryIndex], element: {...leading, prefix: space(" ")}}; + args.splice(factoryIndex, 0, rightPadded({...dependencies, prefix: leading.prefix}, emptySpace)); + factoryIndex += 1; + } + args[factoryIndex] = {...args[factoryIndex], element: restoreFactory(args[factoryIndex].element, factory)}; + return {...call, arguments: {...call.arguments, elements: args}}; +} + +/** Puts the rewritten factory back into whichever wrapper the original was written as. */ +function restoreFactory(original: Expression, factory: J.MethodDeclaration | J.Lambda): Expression { + if (original.kind === JS.Kind.StatementExpression) { + const statementExpression: JS.StatementExpression = { + ...(original as JS.StatementExpression), + statement: factory as J.MethodDeclaration + }; + return statementExpression; + } + const arrowFunction: JS.ArrowFunction = {...(original as JS.ArrowFunction), lambda: factory as J.Lambda}; + return arrowFunction; +} diff --git a/rewrite-javascript/rewrite/test/javascript/amd.test.ts b/rewrite-javascript/rewrite/test/javascript/amd.test.ts index 18edd2aa5b..6939d7df18 100644 --- a/rewrite-javascript/rewrite/test/javascript/amd.test.ts +++ b/rewrite-javascript/rewrite/test/javascript/amd.test.ts @@ -1,7 +1,9 @@ import {JavaScriptParser} from "../../src/javascript"; import {J} from "../../src/java"; import {JS} from "../../src/javascript"; -import {amdBlockOf, dependencyNames, parameterNames} from "../../src/javascript/amd"; +import {amdBlockOf, dependencyNames, parameterNames, withDependency, withoutDependencyAt} from "../../src/javascript/amd"; +import {fromVisitor, RecipeSpec} from "../../src/test"; +import {javascript, JavaScriptVisitor} from "../../src/javascript"; async function firstCall(source: string): Promise { const parser = new JavaScriptParser(); @@ -60,3 +62,74 @@ describe("amdBlockOf", () => { expect(dependencyNames(amdBlockOf(call)!)).toEqual([]); }); }); + +function addDependency(module: string, binding: string) { + return new class extends JavaScriptVisitor { + override async visitMethodInvocation(m: J.MethodInvocation, p: any): Promise { + const block = amdBlockOf(m); + return block === undefined ? super.visitMethodInvocation(m, p) : + withDependency(m, block, module, binding); + } + }; +} + +describe("withDependency", () => { + test("a one-per-line block keeps one per line", async () => { + const spec = new RecipeSpec(); + spec.recipe = fromVisitor(addDependency("c/D", "D")); + await spec.rewriteRun(javascript( + `sap.ui.define([\n "a/B"\n], function (\n B\n) {});`, + `sap.ui.define([\n "a/B",\n "c/D"\n], function (\n B,\n D\n) {});` + )); + }); + + test("an existing trailing comma moves to the entry that ends up last", async () => { + const spec = new RecipeSpec(); + spec.recipe = fromVisitor(addDependency("c/D", "D")); + await spec.rewriteRun(javascript( + `sap.ui.define(["a/B",], function (B,) {});`, + `sap.ui.define(["a/B", "c/D",], function (B, D,) {});` + )); + }); + + test("a block with no dependency array gains one before the factory", async () => { + const spec = new RecipeSpec(); + spec.recipe = fromVisitor(addDependency("c/D", "D")); + await spec.rewriteRun(javascript( + `sap.ui.define(function () {});`, + `sap.ui.define(["c/D"], function (D) {});` + )); + }); +}); + +describe("withoutDependencyAt", () => { + test("removing the last entry hands the closing bracket's whitespace to its predecessor", async () => { + const spec = new RecipeSpec(); + spec.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitMethodInvocation(m: J.MethodInvocation, p: any): Promise { + const block = amdBlockOf(m); + return block === undefined ? super.visitMethodInvocation(m, p) : + withoutDependencyAt(m, block, 1); + } + }); + await spec.rewriteRun(javascript( + `sap.ui.define([\n "a/B",\n "c/D"\n], function (\n B,\n D\n) {});`, + `sap.ui.define([\n "a/B"\n], function (\n B\n) {});` + )); + }); + + test("removing the first entry hands its position to the one that takes its place", async () => { + const spec = new RecipeSpec(); + spec.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitMethodInvocation(m: J.MethodInvocation, p: any): Promise { + const block = amdBlockOf(m); + return block === undefined ? super.visitMethodInvocation(m, p) : + withoutDependencyAt(m, block, 0); + } + }); + await spec.rewriteRun(javascript( + `sap.ui.define(["a/B", "c/D"], function (B, D) {});`, + `sap.ui.define(["c/D"], function (D) {});` + )); + }); +}); From 8808d8caeeac9b17f5ec6ac0d8d42e5678f01b4c Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Thu, 27 Aug 2026 15:41:53 +0200 Subject: [PATCH 06/41] ADR 0013: the read side makes hoisting recipes lane-agnostic --- .../rewrite/test/javascript/debug-amd.test.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 rewrite-javascript/rewrite/test/javascript/debug-amd.test.ts diff --git a/rewrite-javascript/rewrite/test/javascript/debug-amd.test.ts b/rewrite-javascript/rewrite/test/javascript/debug-amd.test.ts new file mode 100644 index 0000000000..5c6644d5ab --- /dev/null +++ b/rewrite-javascript/rewrite/test/javascript/debug-amd.test.ts @@ -0,0 +1,28 @@ +import {JavaScriptParser} from "../../src/javascript"; +import {J} from "../../src/java"; +import {JS} from "../../src/javascript"; + +async function firstCall(source: string): Promise { + const parser = new JavaScriptParser(); + const gen = parser.parse({text: source, sourcePath: "a.js"}); + const parsed = (await gen.next()).value as JS.CompilationUnit; + const statement = parsed.statements[0].element; + return statement as J.MethodInvocation; +} + +describe("debug6", () => { + test("comment after comma on non-last entry", async () => { + const call = await firstCall(`define([\n "a/B", // note\n "c/D"\n], function (B, D) {});`); + const arr = call.arguments.elements[0].element as J.NewArray; + arr.initializer!.elements.forEach((e: any, i: number) => { + console.log(`[${i}] after=`, JSON.stringify(e.after)); + }); + }); + test("comment before comma (attached to entry itself), last entry", async () => { + const call = await firstCall(`define([\n "a/B" // note\n], function (B) {});`); + const arr = call.arguments.elements[0].element as J.NewArray; + arr.initializer!.elements.forEach((e: any, i: number) => { + console.log(`[${i}] after=`, JSON.stringify(e.after)); + }); + }); +}); From abfe510dd60a70f41d5c1b16c333f0db20369582 Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Thu, 27 Aug 2026 15:53:42 +0200 Subject: [PATCH 07/41] JavaScript: normalize arrow parameter lists before editing them 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. --- .../rewrite/src/javascript/amd.ts | 112 ++++++++++++++++-- .../rewrite/test/javascript/amd.test.ts | 65 +++++++++- .../rewrite/test/javascript/debug-amd.test.ts | 28 ----- 3 files changed, 167 insertions(+), 38 deletions(-) delete mode 100644 rewrite-javascript/rewrite/test/javascript/debug-amd.test.ts diff --git a/rewrite-javascript/rewrite/src/javascript/amd.ts b/rewrite-javascript/rewrite/src/javascript/amd.ts index 187f175997..7b3dc67c3e 100644 --- a/rewrite-javascript/rewrite/src/javascript/amd.ts +++ b/rewrite-javascript/rewrite/src/javascript/amd.ts @@ -13,9 +13,21 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import {emptyMarkers} from "../markers"; +import {emptyMarkers, findMarker} from "../markers"; import {randomId} from "../uuid"; -import {emptyContainer, emptySpace, Expression, isIdentifier, isLiteral, J} from "../java"; +import { + emptyContainer, + emptySpace, + Expression, + isIdentifier, + isLiteral, + J, + rightPadded, + space, + spaceContainsNewline, + Statement, + TrailingComma +} from "../java"; import {JS} from "./tree"; /** RequireJS and Dojo write `define`; UI5 namespaces it as `sap.ui.define`. */ @@ -121,7 +133,47 @@ export function elementsOf(block: AmdBlock): J.RightPadded[] { export function parametersOf(block: AmdBlock): J.RightPadded[] { return present(block.factory.kind === J.Kind.MethodDeclaration ? (block.factory as J.MethodDeclaration).parameters.elements : - (block.factory as J.Lambda).parameters.parameters); + normalizeArrowParameters((block.factory as J.Lambda).parameters.parameters)); +} + +/** + * An arrow function's parameter list isn't built the way every other comma-separated list in + * the tree is: a trailing comma is an extra `J.Empty` entry rather than a `TrailingComma` + * marker, and a parameter's own trailing whitespace sits on the identifier's declaration + * rather than on the list entry that holds it. Folding both into the shape the rest of this + * module already handles lets append/remove treat every kind of factory alike. + */ +function normalizeArrowParameters(raw: readonly J.RightPadded[]): J.RightPadded[] { + if (raw.length === 1 && raw[0].element.kind === J.Kind.Empty) { + return [...raw]; + } + const trailingEmpty = raw.length > 1 && raw[raw.length - 1].element.kind === J.Kind.Empty ? + raw[raw.length - 1] : undefined; + const real = (trailingEmpty === undefined ? raw : raw.slice(0, -1)).map(foldDeclarationTrailingSpace); + if (trailingEmpty === undefined) { + return real; + } + const last = real[real.length - 1]; + const marker: TrailingComma = {kind: J.Markers.TrailingComma, id: randomId(), suffix: trailingEmpty.after}; + return [...real.slice(0, -1), {...last, markers: {...last.markers, markers: [...last.markers.markers, marker]}}]; +} + +/** Moves a VariableDeclarations parameter's trailing whitespace from the identifier's own declaration up to the list entry, when the entry doesn't already carry any. */ +function foldDeclarationTrailingSpace(entry: J.RightPadded): J.RightPadded { + if (entry.element.kind !== J.Kind.VariableDeclarations || + entry.after.whitespace !== "" || entry.after.comments.length > 0) { + return entry; + } + const declaration = entry.element as J.VariableDeclarations; + const variable = declaration.variables[0]; + if (variable === undefined) { + return entry; + } + const folded: J.VariableDeclarations = { + ...declaration, + variables: [{...variable, after: emptySpace}, ...declaration.variables.slice(1)] + }; + return {...entry, element: folded, after: variable.after}; } export function dependencyNames(block: AmdBlock): string[] { @@ -143,9 +195,6 @@ export function identifierOf(parameter: J): J.Identifier | undefined { return isIdentifier(parameter) ? parameter : undefined; } -import {findMarker, Markers} from "../markers"; -import {rightPadded, space, spaceContainsNewline, Statement, TrailingComma} from "../java"; - /** * Where an entry's leading whitespace sits. A dependency string carries it directly, while * a factory parameter carries it on the identifier inside the declaration that wraps it. @@ -224,10 +273,24 @@ function appendEntry( return [rightPadded(element, emptySpace)]; } const positioned = slot.withPrefix(element, separator(entries, slot)); - const moved = moveTrailingComma({...last, after: emptySpace}, rightPadded(positioned, last.after)); + const trailing = splitTrailingComments(last.after); + const moved = moveTrailingComma({...last, after: trailing.comments}, rightPadded(positioned, trailing.whitespace)); return [...entries.slice(0, -1), moved.from, moved.to]; } +/** + * Splits an entry's trailing padding when it stops being last. Plain whitespace only ever + * positioned the closing delimiter, so it travels to whichever entry becomes the new last. A + * comment documents the entry it follows and keeps its own trailing newline, so it stays put + * whole — a line comment with nothing left after it would otherwise swallow the comma the + * printer inserts right after this padding. + */ +function splitTrailingComments(after: J.Space): {comments: J.Space, whitespace: J.Space} { + return after.comments.length === 0 ? + {comments: {...after, whitespace: ""}, whitespace: space(after.whitespace)} : + {comments: after, whitespace: emptySpace}; +} + /** * Removes the entry at `index`. The first entry carries no separating whitespace and the * last carries the whitespace before the closing bracket, so removing either hands its @@ -238,6 +301,9 @@ function removeEntry( index: number, slot: Slot ): J.RightPadded[] { + if (index < 0 || index >= entries.length) { + return [...entries]; + } const remaining = [...entries]; const [removed] = remaining.splice(index, 1); if (remaining.length === 0) { @@ -277,7 +343,26 @@ function withParameters( return {...method, parameters: {...method.parameters, elements: elements as J.RightPadded[]}}; } const lambda = factory as J.Lambda; - return {...lambda, parameters: {...lambda.parameters, parameters: elements}}; + // Unparenthesized arrow parameters are only valid JavaScript for exactly one parameter. + if (lambda.parameters.parenthesized || parameters.length === 1) { + return {...lambda, parameters: {...lambda.parameters, parameters: elements}}; + } + return parenthesize(lambda, elements); +} + +/** + * A bare arrow parameter has no closing delimiter of its own — the space before `=>` sits on + * the parameter itself. Wrapping it in parens turns that into the space before the new `)`, + * so it has to move to `arrow` instead, where it will actually print before `=>` again. + */ +function parenthesize(lambda: J.Lambda, elements: J.RightPadded[]): J.Lambda { + const last = elements[elements.length - 1]; + const arrowSpace = last.after.whitespace === "" ? lambda.arrow : last.after; + return { + ...lambda, + parameters: {...lambda.parameters, parenthesized: true, parameters: [...elements.slice(0, -1), {...last, after: emptySpace}]}, + arrow: arrowSpace + }; } function identifier(name: string): J.Identifier { @@ -335,12 +420,21 @@ function parameterDeclaration(name: string): J.VariableDeclarations { }; } +/** + * Adds a dependency and its binding, keeping the array and the parameter list index-aligned. + * Refuses (returns `undefined`) when the factory already declares fewer parameters than there + * are dependencies: padding the gap would invent a binding for a dependency an author left + * unbound on purpose, which silently corrupts the pairing this module exists to protect. + */ export function withDependency( call: J.MethodInvocation, block: AmdBlock, module: string, binding: string -): J.MethodInvocation { +): J.MethodInvocation | undefined { + if (parametersOf(block).length < elementsOf(block).length) { + return undefined; + } const dependencies = withElements( block.dependencies, appendEntry(elementsOf(block), dependencyLiteral(module, quoteOf(block)), dependencySlot)); diff --git a/rewrite-javascript/rewrite/test/javascript/amd.test.ts b/rewrite-javascript/rewrite/test/javascript/amd.test.ts index 6939d7df18..b6d966bb7b 100644 --- a/rewrite-javascript/rewrite/test/javascript/amd.test.ts +++ b/rewrite-javascript/rewrite/test/javascript/amd.test.ts @@ -68,7 +68,7 @@ function addDependency(module: string, binding: string) { override async visitMethodInvocation(m: J.MethodInvocation, p: any): Promise { const block = amdBlockOf(m); return block === undefined ? super.visitMethodInvocation(m, p) : - withDependency(m, block, module, binding); + withDependency(m, block, module, binding) ?? m; } }; } @@ -100,6 +100,48 @@ describe("withDependency", () => { `sap.ui.define(["c/D"], function (D) {});` )); }); + + test("an arrow factory keeps one per line same as a function factory", async () => { + const spec = new RecipeSpec(); + spec.recipe = fromVisitor(addDependency("c/D", "D")); + await spec.rewriteRun(javascript( + `sap.ui.define([\n "a/B"\n], (\n B\n) => {});`, + `sap.ui.define([\n "a/B",\n "c/D"\n], (\n B,\n D\n) => {});` + )); + }); + + test("an arrow factory's trailing comma moves to the entry that ends up last", async () => { + const spec = new RecipeSpec(); + spec.recipe = fromVisitor(addDependency("c/D", "D")); + await spec.rewriteRun(javascript( + `sap.ui.define(["a/B",], (B,) => {});`, + `sap.ui.define(["a/B", "c/D",], (B, D,) => {});` + )); + }); + + test("an unparenthesized single-parameter arrow gains parens as it grows past one", async () => { + const spec = new RecipeSpec(); + spec.recipe = fromVisitor(addDependency("c/D", "D")); + await spec.rewriteRun(javascript( + `sap.ui.define(["a/B"], B => {});`, + `sap.ui.define(["a/B", "c/D"], (B, D) => {});` + )); + }); + + test("refuses when the factory already binds fewer parameters than there are dependencies", async () => { + const call = await firstCall(`define(["a/B", "jquery"], function (B) {});`); + const block = amdBlockOf(call)!; + expect(withDependency(call, block, "c/D", "D")).toBeUndefined(); + }); + + test("a trailing comment stays on the entry it followed instead of relabeling the appended one", async () => { + const spec = new RecipeSpec(); + spec.recipe = fromVisitor(addDependency("c/D", "D")); + await spec.rewriteRun(javascript( + `sap.ui.define([\n "a/B" // note\n], function (\n B\n) {});`, + `sap.ui.define([\n "a/B" // note\n,\n "c/D"], function (\n B,\n D\n) {});` + )); + }); }); describe("withoutDependencyAt", () => { @@ -132,4 +174,25 @@ describe("withoutDependencyAt", () => { `sap.ui.define(["c/D"], function (D) {});` )); }); + + test("removing the only entry leaves an empty array, not a hole", async () => { + const spec = new RecipeSpec(); + spec.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitMethodInvocation(m: J.MethodInvocation, p: any): Promise { + const block = amdBlockOf(m); + return block === undefined ? super.visitMethodInvocation(m, p) : + withoutDependencyAt(m, block, 0); + } + }); + await spec.rewriteRun(javascript( + `sap.ui.define(["a/B"], function (B) {});`, + `sap.ui.define([], function () {});` + )); + }); + + test("an out-of-range index leaves the block unchanged instead of throwing", async () => { + const call = await firstCall(`define(["a/B", "c/D"], function (B, D) {});`); + const block = amdBlockOf(call)!; + expect(dependencyNames(amdBlockOf(withoutDependencyAt(call, block, 2))!)).toEqual(["a/B", "c/D"]); + }); }); diff --git a/rewrite-javascript/rewrite/test/javascript/debug-amd.test.ts b/rewrite-javascript/rewrite/test/javascript/debug-amd.test.ts deleted file mode 100644 index 5c6644d5ab..0000000000 --- a/rewrite-javascript/rewrite/test/javascript/debug-amd.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import {JavaScriptParser} from "../../src/javascript"; -import {J} from "../../src/java"; -import {JS} from "../../src/javascript"; - -async function firstCall(source: string): Promise { - const parser = new JavaScriptParser(); - const gen = parser.parse({text: source, sourcePath: "a.js"}); - const parsed = (await gen.next()).value as JS.CompilationUnit; - const statement = parsed.statements[0].element; - return statement as J.MethodInvocation; -} - -describe("debug6", () => { - test("comment after comma on non-last entry", async () => { - const call = await firstCall(`define([\n "a/B", // note\n "c/D"\n], function (B, D) {});`); - const arr = call.arguments.elements[0].element as J.NewArray; - arr.initializer!.elements.forEach((e: any, i: number) => { - console.log(`[${i}] after=`, JSON.stringify(e.after)); - }); - }); - test("comment before comma (attached to entry itself), last entry", async () => { - const call = await firstCall(`define([\n "a/B" // note\n], function (B) {});`); - const arr = call.arguments.elements[0].element as J.NewArray; - arr.initializer!.elements.forEach((e: any, i: number) => { - console.log(`[${i}] after=`, JSON.stringify(e.after)); - }); - }); -}); From f8e076c08af8e1282cb9887cbe963e39084da801 Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Thu, 27 Aug 2026 16:11:40 +0200 Subject: [PATCH 08/41] JavaScript: bindModule gives a recipe a local binding on either lane --- .../rewrite/src/javascript/add-import.ts | 2 +- .../rewrite/src/javascript/bind-module.ts | 188 +++++++++++++++++- .../test/javascript/bind-module.test.ts | 110 +++++++++- 3 files changed, 295 insertions(+), 5 deletions(-) diff --git a/rewrite-javascript/rewrite/src/javascript/add-import.ts b/rewrite-javascript/rewrite/src/javascript/add-import.ts index 3acfa297eb..8b70d74e10 100644 --- a/rewrite-javascript/rewrite/src/javascript/add-import.ts +++ b/rewrite-javascript/rewrite/src/javascript/add-import.ts @@ -402,7 +402,7 @@ function deconflict(derived: string, taken: Set): string { * sets `value` to the quoted source (`parser.ts` `mapLiteral`), so neither field alone is the * module name. Reuniting them and stripping the quotes yields it for either shape of literal. */ -function moduleNameOf(module: string | J.Literal): string { +export function moduleNameOf(module: string | J.Literal): string { if (typeof module === 'string') { return module; } diff --git a/rewrite-javascript/rewrite/src/javascript/bind-module.ts b/rewrite-javascript/rewrite/src/javascript/bind-module.ts index b5a34d392e..b27716cab6 100644 --- a/rewrite-javascript/rewrite/src/javascript/bind-module.ts +++ b/rewrite-javascript/rewrite/src/javascript/bind-module.ts @@ -13,12 +13,16 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import {J} from "../java"; +import {isIdentifier, J} from "../java"; import {JS} from "./tree"; import {Cursor} from "../tree"; import {JavaScriptVisitor} from "./visitor"; -import {QuoteChar} from "./add-import"; -import {AmdBlock, amdBlockOf, DEFAULT_AMD_CALLEES, dependencyNames, parameterNames, present} from "./amd"; +import {ExecutionContext} from "../execution"; +import {UUID} from "../uuid"; +import {maybeAddImport, moduleNameOf, QuoteChar} from "./add-import"; +import { + AmdBlock, amdBlockOf, DEFAULT_AMD_CALLEES, dependencyNames, elementsOf, parameterNames, present, withDependency +} from "./amd"; export type EsmBindingForm = "default" | "namespace"; @@ -200,3 +204,181 @@ function moduleObjectBindings(cu: JS.CompilationUnit): ModuleObjectBinding[] { } return bindings; } + +/** + * A local binding for `module`, creating one where the file does not already have it, or + * `undefined` where no safe binding exists. + * + * The name is decided from the cursor and returned at once; the edit that creates the binding + * is deferred onto `visitor.afterVisit`, as `maybeAddImport`'s is. + */ +export async function bindModule( + visitor: JavaScriptVisitor, + module: string | J.Literal, + options?: BindModuleOptions +): Promise { + const moduleName = moduleNameOf(module); + const amd = enclosingAmdBlock(visitor, options); + if (amd !== undefined) { + return bindAmd(visitor, amd, moduleName, options); + } + if (compilationUnitOf(visitor) === undefined) { + // Without a compilation unit there is no lane and no lookup, and the caller emits a + // reference against whatever comes back. + return undefined; + } + const bindings = moduleBindings(visitor, options); + const bound = bindings.bindingOf(moduleName); + if (bound !== undefined) { + return bound; + } + if (bindings.moduleSystem === "commonjs") { + // A `require` answers for a module it already binds, but creating one is unimplemented, + // and an `import` in a file that has none would be the wrong form. + return undefined; + } + return maybeAddImport(visitor, { + module, + member: options?.esmForm === "namespace" ? "*" : "default", + preferredName: options?.binding ?? lastSegment(moduleName), + quoteStyle: options?.quoteStyle, + onlyIfReferenced: false + }); +} + +/** The conventional local name for a module: its last path segment. */ +export function lastSegment(module: string): string { + return module.substring(module.lastIndexOf("/") + 1); +} + +async function bindAmd( + visitor: JavaScriptVisitor, + amd: {call: J.MethodInvocation, block: AmdBlock}, + module: string, + options?: BindModuleOptions +): Promise { + const modules = dependencyNames(amd.block); + const bindings = parameterNames(amd.block); + + const declared = modules.indexOf(module); + if (declared >= 0) { + return bindings[declared]; + } + + // A parameter can only be appended at the end, so a block declaring more dependencies than + // the factory takes parameters for would bind the new module against the wrong one. + if (bindings.length < elementsOf(amd.block).length) { + return undefined; + } + + const taken = [ + ...bindings, + ...await declaredNames(amd.block, visitor), + ...reservedNames(visitor, amd.call.id) + ]; + const binding = deconflict(options?.binding ?? lastSegment(module), taken); + visitor.afterVisit.push(new AddAmdDependency(amd.call.id, module, binding, calleesOf(options))); + return binding; +} + +function deconflict(preferred: string, taken: readonly (string | undefined)[]): string { + if (!taken.includes(preferred)) { + return preferred; + } + for (let suffix = 1; ; suffix++) { + const candidate = `${preferred}_${suffix}`; + if (!taken.includes(candidate)) { + return candidate; + } + } +} + +/** The queue is the reservation record, so calls competing for one name resolve against it. */ +function reservedNames(visitor: JavaScriptVisitor, blockId: UUID): string[] { + return (visitor.afterVisit ?? []) + .filter((v): v is AddAmdDependency => v instanceof AddAmdDependency && v.blockId === blockId) + .map(v => v.binding); +} + +/** + * The names the body declares. A new parameter has to avoid all of them, not just the other + * parameters: a `var Log` in the body would shadow a parameter of the same name, and the + * rewritten code would quietly read the local instead of the module. + */ +async function declaredNames(block: AmdBlock, visitor: JavaScriptVisitor): Promise { + const body = bodyOf(block); + if (body === undefined) { + return []; + } + const names: string[] = []; + const collector = new class extends JavaScriptVisitor { + override async visitVariableDeclarations(v: J.VariableDeclarations, c: ExecutionContext) { + for (const variable of v.variables) { + if (isIdentifier(variable.element.name)) { + names.push((variable.element.name as J.Identifier).simpleName); + } + } + return super.visitVariableDeclarations(v, c); + } + + override async visitMethodDeclaration(m: J.MethodDeclaration, c: ExecutionContext) { + names.push(m.name.simpleName); + return super.visitMethodDeclaration(m, c); + } + + override async visitClassDeclaration(k: J.ClassDeclaration, c: ExecutionContext) { + names.push(k.name.simpleName); + return super.visitClassDeclaration(k, c); + } + }; + await collector.visit(body, new ExecutionContext()); + return names; +} + +function bodyOf(block: AmdBlock): J.Block | undefined { + const body = block.factory.kind === J.Kind.MethodDeclaration ? + (block.factory as J.MethodDeclaration).body : + (block.factory as J.Lambda).body; + return body !== undefined && body.kind === J.Kind.Block ? body as J.Block : undefined; +} + +/** + * Applies the dependency a `bindModule` call settled on. The block is found by id: its caller + * has already emitted a reference to the binding, so a block that cannot be found is an error + * rather than a skipped edit. + */ +export class AddAmdDependency

extends JavaScriptVisitor

{ + constructor( + readonly blockId: UUID, + readonly module: string, + readonly binding: string, + readonly callees: readonly string[] + ) { + super(); + } + + private applied = false; + + override async visitJsCompilationUnit(cu: JS.CompilationUnit, p: P): Promise { + const visited = await super.visitJsCompilationUnit(cu, p) as JS.CompilationUnit; + if (!this.applied) { + throw new Error( + `No AMD block ${this.blockId} to declare '${this.module}' on, but '${this.binding}' was reported bound`); + } + return visited; + } + + override async visitMethodInvocation(m: J.MethodInvocation, p: P): Promise { + const visited = await super.visitMethodInvocation(m, p) as J.MethodInvocation; + if (visited.id !== this.blockId) { + return visited; + } + const block = amdBlockOf(visited, this.callees); + if (block === undefined) { + return visited; + } + this.applied = true; + // withDependency only refuses on the parameter gap bindAmd already ruled out before queuing. + return withDependency(visited, block, this.module, this.binding) ?? visited; + } +} diff --git a/rewrite-javascript/rewrite/test/javascript/bind-module.test.ts b/rewrite-javascript/rewrite/test/javascript/bind-module.test.ts index cfe16f69fe..9d70495f1f 100644 --- a/rewrite-javascript/rewrite/test/javascript/bind-module.test.ts +++ b/rewrite-javascript/rewrite/test/javascript/bind-module.test.ts @@ -1,6 +1,6 @@ import {fromVisitor, RecipeSpec} from "../../src/test"; import { - JavaScriptVisitor, JS, javascript, typescript, moduleBindings, isAmdBlock, ModuleBindings + JavaScriptVisitor, JS, javascript, typescript, moduleBindings, isAmdBlock, ModuleBindings, bindModule } from "../../src/javascript"; import {J} from "../../src/java"; @@ -123,3 +123,111 @@ describe("isAmdBlock", () => { expect(blocks).toEqual(["define"]); }); }); + +function rebind(module: string, bound: {name?: string}) { + return new class extends JavaScriptVisitor { + override async visitMethodInvocation(m: J.MethodInvocation, p: any): Promise { + if (m.name.simpleName !== "target") { + return super.visitMethodInvocation(m, p); + } + bound.name = await bindModule(this, module); + return m; + } + }; +} + +describe("bindModule", () => { + test("AMD appends to both lists and reports the new name", async () => { + const spec = new RecipeSpec(); + const bound: {name?: string} = {}; + spec.recipe = fromVisitor(rebind("sap/ui/core/Element", bound)); + await spec.rewriteRun(javascript( + `sap.ui.define(["sap/m/Button"], function (Button) { target(); });`, + `sap.ui.define(["sap/m/Button", "sap/ui/core/Element"], function (Button, Element) { target(); });` + )); + expect(bound.name).toBe("Element"); + }); + + test("AMD reuses an existing dependency's parameter and edits nothing", async () => { + const spec = new RecipeSpec(); + const bound: {name?: string} = {}; + spec.recipe = fromVisitor(rebind("sap/ui/core/Element", bound)); + await spec.rewriteRun(javascript( + `sap.ui.define(["sap/ui/core/Element"], function (Elem) { target(); });` + )); + expect(bound.name).toBe("Elem"); + }); + + test("AMD avoids a name the factory body declares", async () => { + const spec = new RecipeSpec(); + const bound: {name?: string} = {}; + spec.recipe = fromVisitor(rebind("sap/ui/core/Element", bound)); + await spec.rewriteRun(javascript( + `sap.ui.define([], function () { var Element = 1; target(); });`, + `sap.ui.define(["sap/ui/core/Element"], function (Element_1) { var Element = 1; target(); });` + )); + expect(bound.name).toBe("Element_1"); + }); + + test("AMD refuses where a parameter would pair with the wrong dependency", async () => { + const spec = new RecipeSpec(); + const bound: {name?: string} = {}; + spec.recipe = fromVisitor(rebind("sap/ui/core/Element", bound)); + await spec.rewriteRun(javascript( + `sap.ui.define(["sap/m/Button", "x/Y"], function (Button) { target(); });` + )); + expect(bound.name).toBeUndefined(); + }); + + test("ESM creates a default import and reports its name", async () => { + const spec = new RecipeSpec(); + const bound: {name?: string} = {}; + spec.recipe = fromVisitor(rebind("sap/ui/core/Element", bound)); + await spec.rewriteRun(typescript( + `target();`, + `import Element from 'sap/ui/core/Element';\n\ntarget();` + )); + expect(bound.name).toBe("Element"); + }); + + test("ESM reuses a default import bound under another name", async () => { + const spec = new RecipeSpec(); + const bound: {name?: string} = {}; + spec.recipe = fromVisitor(rebind("sap/ui/core/Element", bound)); + await spec.rewriteRun(typescript( + `import Elem from "sap/ui/core/Element";\n\ntarget();` + )); + expect(bound.name).toBe("Elem"); + }); + + test("a CommonJS file answers with the binding its require already has", async () => { + const spec = new RecipeSpec(); + const bound: {name?: string} = {}; + spec.recipe = fromVisitor(rebind("sap/ui/core/Element", bound)); + await spec.rewriteRun(javascript( + `const Elem = require("sap/ui/core/Element");\n\ntarget();` + )); + expect(bound.name).toBe("Elem"); + }); + + test("a CommonJS file refuses rather than gain an import", async () => { + const spec = new RecipeSpec(); + const bound: {name?: string} = {}; + spec.recipe = fromVisitor(rebind("sap/ui/core/Element", bound)); + await spec.rewriteRun(javascript( + `const other = require("a/Other");\n\ntarget();` + )); + expect(bound.name).toBeUndefined(); + }); + + test("a nested require callback is the block a binding belongs to", async () => { + const spec = new RecipeSpec(); + const bound: {name?: string} = {}; + spec.recipe = fromVisitor(rebind("sap/ui/core/Element", bound)); + await spec.rewriteRun(javascript( + `sap.ui.define(["a/B"], function (B) { sap.ui.require([], function () { target(); }); });`, + `sap.ui.define(["a/B"], function (B) { sap.ui.require(["sap/ui/core/Element"], function (Element) { target(); }); });` + )); + expect(bound.name).toBe("Element"); + }); +}); From e3d297c9ae2f17ba3408e4af8f4bb3b13f299247 Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Thu, 27 Aug 2026 16:17:40 +0200 Subject: [PATCH 09/41] JavaScript: bindModule's AMD gate skips a binding nothing goes on to 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 .target() so the gate's positive case is real, and a new askAndAbandon()-based test pins the negative case. --- .../rewrite/src/javascript/bind-module.ts | 28 +++++++- .../test/javascript/bind-module.test.ts | 65 ++++++++++++++++--- 2 files changed, 82 insertions(+), 11 deletions(-) diff --git a/rewrite-javascript/rewrite/src/javascript/bind-module.ts b/rewrite-javascript/rewrite/src/javascript/bind-module.ts index b27716cab6..c9ec95dc2c 100644 --- a/rewrite-javascript/rewrite/src/javascript/bind-module.ts +++ b/rewrite-javascript/rewrite/src/javascript/bind-module.ts @@ -342,6 +342,28 @@ function bodyOf(block: AmdBlock): J.Block | undefined { return body !== undefined && body.kind === J.Kind.Block ? body as J.Block : undefined; } +/** + * Whether the factory body references `name`. The AMD binding is a plain parameter, so a name + * match is the whole test — unlike the ESM lane's `onlyIfReferenced`, no attribution is involved. + */ +async function references(block: AmdBlock, name: string): Promise { + const body = bodyOf(block); + if (body === undefined) { + return false; + } + let found = false; + const finder = new class extends JavaScriptVisitor { + override async visitIdentifier(id: J.Identifier, c: ExecutionContext) { + if (id.simpleName === name) { + found = true; + } + return super.visitIdentifier(id, c); + } + }; + await finder.visit(body, new ExecutionContext()); + return found; +} + /** * Applies the dependency a `bindModule` call settled on. The block is found by id: its caller * has already emitted a reference to the binding, so a block that cannot be found is an error @@ -378,7 +400,9 @@ export class AddAmdDependency

extends JavaScriptVisitor

{ return visited; } this.applied = true; - // withDependency only refuses on the parameter gap bindAmd already ruled out before queuing. - return withDependency(visited, block, this.module, this.binding) ?? visited; + // Nothing referenced the binding, so the caller asked and then did not use the answer. + return (await references(block, this.binding)) ? + withDependency(visited, block, this.module, this.binding) : + visited; } } diff --git a/rewrite-javascript/rewrite/test/javascript/bind-module.test.ts b/rewrite-javascript/rewrite/test/javascript/bind-module.test.ts index 9d70495f1f..bd7c142a61 100644 --- a/rewrite-javascript/rewrite/test/javascript/bind-module.test.ts +++ b/rewrite-javascript/rewrite/test/javascript/bind-module.test.ts @@ -2,7 +2,9 @@ import {fromVisitor, RecipeSpec} from "../../src/test"; import { JavaScriptVisitor, JS, javascript, typescript, moduleBindings, isAmdBlock, ModuleBindings, bindModule } from "../../src/javascript"; -import {J} from "../../src/java"; +import {emptySpace, J, rightPadded} from "../../src/java"; +import {emptyMarkers} from "../../src/markers"; +import {randomId} from "../../src/uuid"; function captureBindings(seen: {moduleSystem?: string, module?: string, binding?: string}, localName: string = "Button", moduleName: string = "sap/m/Button") { @@ -124,7 +126,39 @@ describe("isAmdBlock", () => { }); }); +function identifierRef(name: string): J.Identifier { + return { + kind: J.Kind.Identifier, + id: randomId(), + prefix: emptySpace, + markers: emptyMarkers, + annotations: [], + simpleName: name, + type: undefined, + fieldType: undefined + }; +} + +/** A caller that uses the answer: rewrites `target()` to `.target()`, the way a real + * recipe would use the name `bindModule` returned. */ function rebind(module: string, bound: {name?: string}) { + return new class extends JavaScriptVisitor { + override async visitMethodInvocation(m: J.MethodInvocation, p: any): Promise { + if (m.name.simpleName !== "target") { + return super.visitMethodInvocation(m, p); + } + bound.name = await bindModule(this, module); + if (bound.name === undefined) { + return m; + } + const rebound: J.MethodInvocation = {...m, select: rightPadded(identifierRef(bound.name), emptySpace)}; + return rebound; + } + }; +} + +/** A caller that asks, then abandons: takes the name but never uses it. */ +function askAndAbandon(module: string, bound: {name?: string}) { return new class extends JavaScriptVisitor { override async visitMethodInvocation(m: J.MethodInvocation, p: any): Promise { if (m.name.simpleName !== "target") { @@ -143,17 +177,18 @@ describe("bindModule", () => { spec.recipe = fromVisitor(rebind("sap/ui/core/Element", bound)); await spec.rewriteRun(javascript( `sap.ui.define(["sap/m/Button"], function (Button) { target(); });`, - `sap.ui.define(["sap/m/Button", "sap/ui/core/Element"], function (Button, Element) { target(); });` + `sap.ui.define(["sap/m/Button", "sap/ui/core/Element"], function (Button, Element) { Element.target(); });` )); expect(bound.name).toBe("Element"); }); - test("AMD reuses an existing dependency's parameter and edits nothing", async () => { + test("AMD reuses an existing dependency's parameter and edits nothing on that lane", async () => { const spec = new RecipeSpec(); const bound: {name?: string} = {}; spec.recipe = fromVisitor(rebind("sap/ui/core/Element", bound)); await spec.rewriteRun(javascript( - `sap.ui.define(["sap/ui/core/Element"], function (Elem) { target(); });` + `sap.ui.define(["sap/ui/core/Element"], function (Elem) { target(); });`, + `sap.ui.define(["sap/ui/core/Element"], function (Elem) { Elem.target(); });` )); expect(bound.name).toBe("Elem"); }); @@ -164,7 +199,7 @@ describe("bindModule", () => { spec.recipe = fromVisitor(rebind("sap/ui/core/Element", bound)); await spec.rewriteRun(javascript( `sap.ui.define([], function () { var Element = 1; target(); });`, - `sap.ui.define(["sap/ui/core/Element"], function (Element_1) { var Element = 1; target(); });` + `sap.ui.define(["sap/ui/core/Element"], function (Element_1) { var Element = 1; Element_1.target(); });` )); expect(bound.name).toBe("Element_1"); }); @@ -179,13 +214,23 @@ describe("bindModule", () => { expect(bound.name).toBeUndefined(); }); + test("a binding nothing goes on to reference is not written", async () => { + const spec = new RecipeSpec(); + const bound: {name?: string} = {}; + spec.recipe = fromVisitor(askAndAbandon("sap/ui/core/Element", bound)); + await spec.rewriteRun(javascript( + `sap.ui.define(["sap/m/Button"], function (Button) { target(); });` + )); + expect(bound.name).toBe("Element"); + }); + test("ESM creates a default import and reports its name", async () => { const spec = new RecipeSpec(); const bound: {name?: string} = {}; spec.recipe = fromVisitor(rebind("sap/ui/core/Element", bound)); await spec.rewriteRun(typescript( `target();`, - `import Element from 'sap/ui/core/Element';\n\ntarget();` + `import Element from 'sap/ui/core/Element';\n\nElement.target();` )); expect(bound.name).toBe("Element"); }); @@ -195,7 +240,8 @@ describe("bindModule", () => { const bound: {name?: string} = {}; spec.recipe = fromVisitor(rebind("sap/ui/core/Element", bound)); await spec.rewriteRun(typescript( - `import Elem from "sap/ui/core/Element";\n\ntarget();` + `import Elem from "sap/ui/core/Element";\n\ntarget();`, + `import Elem from "sap/ui/core/Element";\n\nElem.target();` )); expect(bound.name).toBe("Elem"); }); @@ -205,7 +251,8 @@ describe("bindModule", () => { const bound: {name?: string} = {}; spec.recipe = fromVisitor(rebind("sap/ui/core/Element", bound)); await spec.rewriteRun(javascript( - `const Elem = require("sap/ui/core/Element");\n\ntarget();` + `const Elem = require("sap/ui/core/Element");\n\ntarget();`, + `const Elem = require("sap/ui/core/Element");\n\nElem.target();` )); expect(bound.name).toBe("Elem"); }); @@ -226,7 +273,7 @@ describe("bindModule", () => { spec.recipe = fromVisitor(rebind("sap/ui/core/Element", bound)); await spec.rewriteRun(javascript( `sap.ui.define(["a/B"], function (B) { sap.ui.require([], function () { target(); }); });`, - `sap.ui.define(["a/B"], function (B) { sap.ui.require(["sap/ui/core/Element"], function (Element) { target(); }); });` + `sap.ui.define(["a/B"], function (B) { sap.ui.require(["sap/ui/core/Element"], function (Element) { Element.target(); }); });` )); expect(bound.name).toBe("Element"); }); From fb92e298dc631a1bf87c144633442ef83a7f56cc Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Thu, 27 Aug 2026 16:35:45 +0200 Subject: [PATCH 10/41] JavaScript: bindModule dedupes AMD reservations and never deletes the 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. --- .../rewrite/src/javascript/bind-module.ts | 37 +++--- .../test/javascript/bind-module.test.ts | 118 ++++++++++++++++-- 2 files changed, 134 insertions(+), 21 deletions(-) diff --git a/rewrite-javascript/rewrite/src/javascript/bind-module.ts b/rewrite-javascript/rewrite/src/javascript/bind-module.ts index c9ec95dc2c..3d91aa96a0 100644 --- a/rewrite-javascript/rewrite/src/javascript/bind-module.ts +++ b/rewrite-javascript/rewrite/src/javascript/bind-module.ts @@ -265,17 +265,21 @@ async function bindAmd( return bindings[declared]; } + // Two calls naming the same module at the same block are the same request (ADR 0013), + // answered from whichever reservation is already queued rather than doubling the dependency. + const queued = queuedFor(visitor, amd.call.id); + const reserved = queued.find(v => v.module === module)?.binding; + if (reserved !== undefined) { + return reserved; + } + // A parameter can only be appended at the end, so a block declaring more dependencies than // the factory takes parameters for would bind the new module against the wrong one. if (bindings.length < elementsOf(amd.block).length) { return undefined; } - const taken = [ - ...bindings, - ...await declaredNames(amd.block, visitor), - ...reservedNames(visitor, amd.call.id) - ]; + const taken = [...bindings, ...await declaredNames(amd.block, visitor), ...queued.map(v => v.binding)]; const binding = deconflict(options?.binding ?? lastSegment(module), taken); visitor.afterVisit.push(new AddAmdDependency(amd.call.id, module, binding, calleesOf(options))); return binding; @@ -293,11 +297,9 @@ function deconflict(preferred: string, taken: readonly (string | undefined)[]): } } -/** The queue is the reservation record, so calls competing for one name resolve against it. */ -function reservedNames(visitor: JavaScriptVisitor, blockId: UUID): string[] { - return (visitor.afterVisit ?? []) - .filter((v): v is AddAmdDependency => v instanceof AddAmdDependency && v.blockId === blockId) - .map(v => v.binding); +/** Queued reservations already targeting this block: the shared source for both dedup and deconfliction. */ +function queuedFor(visitor: JavaScriptVisitor, blockId: UUID): AddAmdDependency[] { + return visitor.afterVisit.filter((v): v is AddAmdDependency => v instanceof AddAmdDependency && v.blockId === blockId); } /** @@ -345,6 +347,9 @@ function bodyOf(block: AmdBlock): J.Block | undefined { /** * Whether the factory body references `name`. The AMD binding is a plain parameter, so a name * match is the whole test — unlike the ESM lane's `onlyIfReferenced`, no attribution is involved. + * The pool is wider than `declaredNames`'s: it matches any identifier of that name, including a + * member name in a field access, so a name `deconflict` treats as free can still satisfy this — + * a stray dependency, not broken code. */ async function references(block: AmdBlock, name: string): Promise { const body = bodyOf(block); @@ -400,9 +405,13 @@ export class AddAmdDependency

extends JavaScriptVisitor

{ return visited; } this.applied = true; - // Nothing referenced the binding, so the caller asked and then did not use the answer. - return (await references(block, this.binding)) ? - withDependency(visited, block, this.module, this.binding) : - visited; + if (!(await references(block, this.binding))) { + // Nothing referenced the binding, so the caller asked and then did not use the answer. + return visited; + } + // withDependency re-checks the parameter gap against the tree as it stands now: another + // edit in the same visit that drops a factory parameter can reopen a gap bindAmd's own + // check already cleared, and returning its `undefined` here would delete this node. + return withDependency(visited, block, this.module, this.binding) ?? visited; } } diff --git a/rewrite-javascript/rewrite/test/javascript/bind-module.test.ts b/rewrite-javascript/rewrite/test/javascript/bind-module.test.ts index bd7c142a61..c7bcc438d9 100644 --- a/rewrite-javascript/rewrite/test/javascript/bind-module.test.ts +++ b/rewrite-javascript/rewrite/test/javascript/bind-module.test.ts @@ -139,8 +139,12 @@ function identifierRef(name: string): J.Identifier { }; } -/** A caller that uses the answer: rewrites `target()` to `.target()`, the way a real - * recipe would use the name `bindModule` returned. */ +/** Rewrites `call` to read as `.call`, the way a real recipe uses the name `bindModule` returned. */ +function withReference(call: J.MethodInvocation, name: string): J.MethodInvocation { + return {...call, select: rightPadded(identifierRef(name), emptySpace)}; +} + +/** A caller that uses the answer. */ function rebind(module: string, bound: {name?: string}) { return new class extends JavaScriptVisitor { override async visitMethodInvocation(m: J.MethodInvocation, p: any): Promise { @@ -148,11 +152,7 @@ function rebind(module: string, bound: {name?: string}) { return super.visitMethodInvocation(m, p); } bound.name = await bindModule(this, module); - if (bound.name === undefined) { - return m; - } - const rebound: J.MethodInvocation = {...m, select: rightPadded(identifierRef(bound.name), emptySpace)}; - return rebound; + return bound.name === undefined ? m : withReference(m, bound.name); } }; } @@ -224,6 +224,110 @@ describe("bindModule", () => { expect(bound.name).toBe("Element"); }); + test("two calls for the same module at one block share the reservation", async () => { + const spec = new RecipeSpec(); + const bound1: {name?: string} = {}; + const bound2: {name?: string} = {}; + spec.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitMethodInvocation(m: J.MethodInvocation, p: any): Promise { + if (m.name.simpleName !== "target") { + return super.visitMethodInvocation(m, p); + } + const bound = bound1.name === undefined ? bound1 : bound2; + bound.name = await bindModule(this, "sap/ui/core/Element"); + return bound.name === undefined ? m : withReference(m, bound.name); + } + }); + await spec.rewriteRun(javascript( + `sap.ui.define([], function () { target(); target(); });`, + `sap.ui.define(["sap/ui/core/Element"], function (Element) { Element.target(); Element.target(); });` + )); + expect(bound1.name).toBe("Element"); + expect(bound2.name).toBe("Element"); + }); + + test("two different modules whose preferred names collide deconflict against each other", async () => { + const spec = new RecipeSpec(); + const boundA: {name?: string} = {}; + const boundB: {name?: string} = {}; + spec.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitMethodInvocation(m: J.MethodInvocation, p: any): Promise { + if (m.name.simpleName === "targetA") { + boundA.name = await bindModule(this, "a/Element"); + return boundA.name === undefined ? m : withReference(m, boundA.name); + } + if (m.name.simpleName === "targetB") { + boundB.name = await bindModule(this, "b/Element"); + return boundB.name === undefined ? m : withReference(m, boundB.name); + } + return super.visitMethodInvocation(m, p); + } + }); + await spec.rewriteRun(javascript( + `sap.ui.define([], function () { targetA(); targetB(); });`, + `sap.ui.define(["a/Element", "b/Element"], function (Element, Element_1) { Element.targetA(); Element_1.targetB(); });` + )); + expect(boundA.name).toBe("Element"); + expect(boundB.name).toBe("Element_1"); + }); + + test("a block replaced before the deferred edit runs is an error, not a silent skip", async () => { + const spec = new RecipeSpec(); + const bound: {name?: string} = {}; + spec.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitMethodInvocation(m: J.MethodInvocation, p: any): Promise { + if (m.name.simpleName === "target") { + bound.name = await bindModule(this, "sap/ui/core/Element"); + return m; + } + const visited = await super.visitMethodInvocation(m, p) as J.MethodInvocation; + // Simulate an unrelated edit that replaces the block's own node identity, so the + // id the deferred edit was queued against no longer exists anywhere in the tree. + return isAmdBlock(visited) ? {...visited, id: randomId()} : visited; + } + }); + await expect(spec.rewriteRun(javascript( + `sap.ui.define(["sap/m/Button"], function (Button) { target(); });` + ))).rejects.toThrow(/No AMD block/); + }); + + test("a parameter dropped elsewhere in the same visit does not delete the block", async () => { + const spec = new RecipeSpec(); + const bound: {name?: string} = {}; + spec.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitMethodInvocation(m: J.MethodInvocation, p: any): Promise { + if (m.name.simpleName === "target") { + bound.name = await bindModule(this, "sap/ui/core/Element"); + return bound.name === undefined ? m : withReference(m, bound.name); + } + const visited = await super.visitMethodInvocation(m, p) as J.MethodInvocation; + if (!isAmdBlock(visited)) { + return visited; + } + // Simulate an unrelated edit in the same visit that drops the factory's only + // parameter without removing the dependency it was paired to, reopening the gap + // bindAmd's own check had already cleared. + const factoryArg = visited.arguments.elements[1].element as JS.StatementExpression; + const method = factoryArg.statement as J.MethodDeclaration; + const empty: J.Empty = {kind: J.Kind.Empty, id: randomId(), prefix: emptySpace, markers: emptyMarkers}; + const stripped: J.MethodDeclaration = { + ...method, + parameters: {...method.parameters, elements: [rightPadded(empty, emptySpace)]} + }; + const strippedFactory: JS.StatementExpression = {...factoryArg, statement: stripped}; + const args = [...visited.arguments.elements]; + args[1] = {...args[1], element: strippedFactory}; + const withStrippedFactory: J.MethodInvocation = {...visited, arguments: {...visited.arguments, elements: args}}; + return withStrippedFactory; + } + }); + await spec.rewriteRun(javascript( + `sap.ui.define(["a/B"], function (B) { target(); });`, + `sap.ui.define(["a/B"], function () { Element.target(); });` + )); + expect(bound.name).toBe("Element"); + }); + test("ESM creates a default import and reports its name", async () => { const spec = new RecipeSpec(); const bound: {name?: string} = {}; From 5fc933f496f91ceda29f741b0605c26a94a2e239 Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Thu, 27 Aug 2026 16:43:04 +0200 Subject: [PATCH 11/41] JavaScript: maybeRemoveImport drops an unused AMD dependency --- .../rewrite/src/javascript/index.ts | 1 + .../src/javascript/remove-amd-dependency.ts | 66 +++++++++++++++++++ .../rewrite/src/javascript/remove-import.ts | 4 ++ .../test/javascript/bind-module.test.ts | 39 ++++++++++- 4 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 rewrite-javascript/rewrite/src/javascript/remove-amd-dependency.ts diff --git a/rewrite-javascript/rewrite/src/javascript/index.ts b/rewrite-javascript/rewrite/src/javascript/index.ts index 0b730a0ac9..ad503c4b7b 100644 --- a/rewrite-javascript/rewrite/src/javascript/index.ts +++ b/rewrite-javascript/rewrite/src/javascript/index.ts @@ -36,6 +36,7 @@ export * from "./add-import"; export * from "./amd"; export * from "./bind-module"; export * from "./remove-import"; +export * from "./remove-amd-dependency"; export * from "./cleanup/index"; export * from "./recipes/index"; export * from "./search/index"; diff --git a/rewrite-javascript/rewrite/src/javascript/remove-amd-dependency.ts b/rewrite-javascript/rewrite/src/javascript/remove-amd-dependency.ts new file mode 100644 index 0000000000..84d78d379b --- /dev/null +++ b/rewrite-javascript/rewrite/src/javascript/remove-amd-dependency.ts @@ -0,0 +1,66 @@ +/* + * Copyright 2025 the original author or authors. + *

+ * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://docs.moderne.io/licensing/moderne-source-available-license + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import {J} from "../java"; +import {JavaScriptVisitor} from "./visitor"; +import {AmdBlock, amdBlockOf, DEFAULT_AMD_CALLEES, dependencyNames, parameterNames, withoutDependencyAt} from "./amd"; + +/** + * The AMD counterpart to `RemoveImport`: drops a dependency the block's body no longer names, + * along with the parameter bound to it. A dependency the factory takes no parameter for is + * loaded for its side effects and stays, matching `bindAmd`'s own read of such a block. + */ +export class RemoveAmdDependency

extends JavaScriptVisitor

{ + constructor(readonly module: string, readonly callees: readonly string[] = DEFAULT_AMD_CALLEES) { + super(); + } + + override async visitMethodInvocation(m: J.MethodInvocation, p: P): Promise { + const visited = await super.visitMethodInvocation(m, p) as J.MethodInvocation; + const block = amdBlockOf(visited, this.callees); + if (block === undefined) { + return visited; + } + const index = dependencyNames(block).indexOf(this.module); + if (index < 0) { + return visited; + } + const binding = parameterNames(block)[index]; + if (binding === undefined || await this.references(block, binding, p)) { + return visited; + } + return withoutDependencyAt(visited, block, index); + } + + private async references(block: AmdBlock, binding: string, p: P): Promise { + const body = block.factory.kind === J.Kind.MethodDeclaration ? + (block.factory as J.MethodDeclaration).body : + (block.factory as J.Lambda).body; + if (body === undefined) { + return true; + } + let found = false; + const search = new class extends JavaScriptVisitor

{ + override async visitIdentifier(identifier: J.Identifier, c: P): Promise { + if (identifier.simpleName === binding) { + found = true; + } + return identifier; + } + }; + await search.visit(body, p); + return found; + } +} diff --git a/rewrite-javascript/rewrite/src/javascript/remove-import.ts b/rewrite-javascript/rewrite/src/javascript/remove-import.ts index 370712600e..aeff052799 100644 --- a/rewrite-javascript/rewrite/src/javascript/remove-import.ts +++ b/rewrite-javascript/rewrite/src/javascript/remove-import.ts @@ -4,6 +4,7 @@ import {bindingNames} from "./scope"; import {JS, JSX} from "./tree"; import {mapAsync, updateIfChanged} from "../util"; import {ElementRemovalFormatter} from "../java"; +import {RemoveAmdDependency} from "./remove-amd-dependency"; /** * @param visitor The visitor to add the import removal to @@ -38,6 +39,9 @@ export function maybeRemoveImport(visitor: JavaScriptVisitor, module: strin } } visitor.afterVisit.push(new RemoveImport(module, member)); + // Whichever lane the file turns out to use, the other visitor finds nothing to do, so the + // caller need not know which one it is in. + visitor.afterVisit.push(new RemoveAmdDependency(module)); } // Type alias for RightPadded elements to simplify type signatures diff --git a/rewrite-javascript/rewrite/test/javascript/bind-module.test.ts b/rewrite-javascript/rewrite/test/javascript/bind-module.test.ts index c7bcc438d9..eec9f587d3 100644 --- a/rewrite-javascript/rewrite/test/javascript/bind-module.test.ts +++ b/rewrite-javascript/rewrite/test/javascript/bind-module.test.ts @@ -1,6 +1,7 @@ import {fromVisitor, RecipeSpec} from "../../src/test"; import { - JavaScriptVisitor, JS, javascript, typescript, moduleBindings, isAmdBlock, ModuleBindings, bindModule + JavaScriptVisitor, JS, javascript, typescript, moduleBindings, isAmdBlock, ModuleBindings, bindModule, + maybeRemoveImport } from "../../src/javascript"; import {emptySpace, J, rightPadded} from "../../src/java"; import {emptyMarkers} from "../../src/markers"; @@ -382,3 +383,39 @@ describe("bindModule", () => { expect(bound.name).toBe("Element"); }); }); + +function dropModule(module: string) { + return new class extends JavaScriptVisitor { + override async visitJsCompilationUnit(cu: JS.CompilationUnit, p: any): Promise { + maybeRemoveImport(this, module); + return super.visitJsCompilationUnit(cu, p); + } + }; +} + +describe("maybeRemoveImport on an AMD block", () => { + test("an unreferenced dependency goes with its parameter", async () => { + const spec = new RecipeSpec(); + spec.recipe = fromVisitor(dropModule("sap/m/Button")); + await spec.rewriteRun(javascript( + `sap.ui.define(["sap/m/Button", "a/C"], function (Button, C) { C.f(); });`, + `sap.ui.define(["a/C"], function (C) { C.f(); });` + )); + }); + + test("a dependency the body still names stays", async () => { + const spec = new RecipeSpec(); + spec.recipe = fromVisitor(dropModule("sap/m/Button")); + await spec.rewriteRun(javascript( + `sap.ui.define(["sap/m/Button"], function (Button) { Button.f(); });` + )); + }); + + test("a dependency bound to no parameter is left alone", async () => { + const spec = new RecipeSpec(); + spec.recipe = fromVisitor(dropModule("a/Side")); + await spec.rewriteRun(javascript( + `sap.ui.define(["a/Side"], function () {});` + )); + }); +}); From e06bf840295fc583266e80f06bfc5c1e6ae4e8d3 Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Thu, 27 Aug 2026 16:56:02 +0200 Subject: [PATCH 12/41] JavaScript: bindModule/withDependency refuse on any parameter/dependency 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. --- .../rewrite/src/javascript/amd.ts | 9 +++--- .../rewrite/src/javascript/bind-module.ts | 28 +++++++++---------- .../src/javascript/remove-amd-dependency.ts | 25 ++--------------- .../rewrite/src/javascript/remove-import.ts | 2 ++ .../rewrite/test/javascript/amd.test.ts | 6 ++++ .../test/javascript/bind-module.test.ts | 21 ++++++++++++++ 6 files changed, 51 insertions(+), 40 deletions(-) diff --git a/rewrite-javascript/rewrite/src/javascript/amd.ts b/rewrite-javascript/rewrite/src/javascript/amd.ts index 7b3dc67c3e..9c9801d367 100644 --- a/rewrite-javascript/rewrite/src/javascript/amd.ts +++ b/rewrite-javascript/rewrite/src/javascript/amd.ts @@ -422,9 +422,10 @@ function parameterDeclaration(name: string): J.VariableDeclarations { /** * Adds a dependency and its binding, keeping the array and the parameter list index-aligned. - * Refuses (returns `undefined`) when the factory already declares fewer parameters than there - * are dependencies: padding the gap would invent a binding for a dependency an author left - * unbound on purpose, which silently corrupts the pairing this module exists to protect. + * Refuses (returns `undefined`) unless the two are already the same length: fewer parameters + * than dependencies would invent a binding for one an author left unbound on purpose, and more + * would pair the new dependency with an existing surplus parameter instead of a fresh one — + * either way corrupting the pairing this module exists to protect. */ export function withDependency( call: J.MethodInvocation, @@ -432,7 +433,7 @@ export function withDependency( module: string, binding: string ): J.MethodInvocation | undefined { - if (parametersOf(block).length < elementsOf(block).length) { + if (parametersOf(block).length !== elementsOf(block).length) { return undefined; } const dependencies = withElements( diff --git a/rewrite-javascript/rewrite/src/javascript/bind-module.ts b/rewrite-javascript/rewrite/src/javascript/bind-module.ts index 3d91aa96a0..646f041a54 100644 --- a/rewrite-javascript/rewrite/src/javascript/bind-module.ts +++ b/rewrite-javascript/rewrite/src/javascript/bind-module.ts @@ -273,9 +273,9 @@ async function bindAmd( return reserved; } - // A parameter can only be appended at the end, so a block declaring more dependencies than - // the factory takes parameters for would bind the new module against the wrong one. - if (bindings.length < elementsOf(amd.block).length) { + // A parameter can only be appended at the end, so a block whose dependency and parameter + // counts already disagree would bind the new module against the wrong parameter either way. + if (bindings.length !== elementsOf(amd.block).length) { return undefined; } @@ -337,24 +337,24 @@ async function declaredNames(block: AmdBlock, visitor: JavaScriptVisitor): return names; } -function bodyOf(block: AmdBlock): J.Block | undefined { - const body = block.factory.kind === J.Kind.MethodDeclaration ? +/** The factory's body: a `J.Block` for `function(){}` and most arrows, or the bare expression for `() => expr`. */ +export function bodyOf(block: AmdBlock): J | undefined { + return block.factory.kind === J.Kind.MethodDeclaration ? (block.factory as J.MethodDeclaration).body : (block.factory as J.Lambda).body; - return body !== undefined && body.kind === J.Kind.Block ? body as J.Block : undefined; } /** - * Whether the factory body references `name`. The AMD binding is a plain parameter, so a name - * match is the whole test — unlike the ESM lane's `onlyIfReferenced`, no attribution is involved. - * The pool is wider than `declaredNames`'s: it matches any identifier of that name, including a - * member name in a field access, so a name `deconflict` treats as free can still satisfy this — - * a stray dependency, not broken code. + * Whether the factory body references `name`. The pool is wider than `declaredNames`'s: it + * matches any identifier of that name, including a member in a field access, since a stray + * dependency is fine where a shadowed one is not. `missingBodyAnswer` covers a body-less + * factory: `false` for an addition (nothing to conflict with), `true` for a removal (nothing + * to prove the binding unused). */ -async function references(block: AmdBlock, name: string): Promise { +export async function references(block: AmdBlock, name: string, missingBodyAnswer: boolean): Promise { const body = bodyOf(block); if (body === undefined) { - return false; + return missingBodyAnswer; } let found = false; const finder = new class extends JavaScriptVisitor { @@ -405,7 +405,7 @@ export class AddAmdDependency

extends JavaScriptVisitor

{ return visited; } this.applied = true; - if (!(await references(block, this.binding))) { + if (!(await references(block, this.binding, false))) { // Nothing referenced the binding, so the caller asked and then did not use the answer. return visited; } diff --git a/rewrite-javascript/rewrite/src/javascript/remove-amd-dependency.ts b/rewrite-javascript/rewrite/src/javascript/remove-amd-dependency.ts index 84d78d379b..6105c467e7 100644 --- a/rewrite-javascript/rewrite/src/javascript/remove-amd-dependency.ts +++ b/rewrite-javascript/rewrite/src/javascript/remove-amd-dependency.ts @@ -15,7 +15,8 @@ */ import {J} from "../java"; import {JavaScriptVisitor} from "./visitor"; -import {AmdBlock, amdBlockOf, DEFAULT_AMD_CALLEES, dependencyNames, parameterNames, withoutDependencyAt} from "./amd"; +import {amdBlockOf, DEFAULT_AMD_CALLEES, dependencyNames, parameterNames, withoutDependencyAt} from "./amd"; +import {references} from "./bind-module"; /** * The AMD counterpart to `RemoveImport`: drops a dependency the block's body no longer names, @@ -38,29 +39,9 @@ export class RemoveAmdDependency

extends JavaScriptVisitor

{ return visited; } const binding = parameterNames(block)[index]; - if (binding === undefined || await this.references(block, binding, p)) { + if (binding === undefined || await references(block, binding, true)) { return visited; } return withoutDependencyAt(visited, block, index); } - - private async references(block: AmdBlock, binding: string, p: P): Promise { - const body = block.factory.kind === J.Kind.MethodDeclaration ? - (block.factory as J.MethodDeclaration).body : - (block.factory as J.Lambda).body; - if (body === undefined) { - return true; - } - let found = false; - const search = new class extends JavaScriptVisitor

{ - override async visitIdentifier(identifier: J.Identifier, c: P): Promise { - if (identifier.simpleName === binding) { - found = true; - } - return identifier; - } - }; - await search.visit(body, p); - return found; - } } diff --git a/rewrite-javascript/rewrite/src/javascript/remove-import.ts b/rewrite-javascript/rewrite/src/javascript/remove-import.ts index aeff052799..f72f4f71aa 100644 --- a/rewrite-javascript/rewrite/src/javascript/remove-import.ts +++ b/rewrite-javascript/rewrite/src/javascript/remove-import.ts @@ -15,6 +15,8 @@ import {RemoveAmdDependency} from "./remove-amd-dependency"; * - 'default': Removes the default import from the module if unused, * regardless of its local name (e.g., `import React from 'react'`) * - '*': Removes the namespace import if unused (e.g., `import * as fs from 'fs'`) + * Applies to the ESM lane only; an AMD dependency binds a whole module, so `member` + * is ignored there. * * @example * // Remove a specific named import if unused diff --git a/rewrite-javascript/rewrite/test/javascript/amd.test.ts b/rewrite-javascript/rewrite/test/javascript/amd.test.ts index b6d966bb7b..592e8f4158 100644 --- a/rewrite-javascript/rewrite/test/javascript/amd.test.ts +++ b/rewrite-javascript/rewrite/test/javascript/amd.test.ts @@ -134,6 +134,12 @@ describe("withDependency", () => { expect(withDependency(call, block, "c/D", "D")).toBeUndefined(); }); + test("refuses when the factory already binds more parameters than there are dependencies", async () => { + const call = await firstCall(`define(["a/B"], function (B, Extra) {});`); + const block = amdBlockOf(call)!; + expect(withDependency(call, block, "c/D", "D")).toBeUndefined(); + }); + test("a trailing comment stays on the entry it followed instead of relabeling the appended one", async () => { const spec = new RecipeSpec(); spec.recipe = fromVisitor(addDependency("c/D", "D")); diff --git a/rewrite-javascript/rewrite/test/javascript/bind-module.test.ts b/rewrite-javascript/rewrite/test/javascript/bind-module.test.ts index eec9f587d3..1001bc1307 100644 --- a/rewrite-javascript/rewrite/test/javascript/bind-module.test.ts +++ b/rewrite-javascript/rewrite/test/javascript/bind-module.test.ts @@ -215,6 +215,16 @@ describe("bindModule", () => { expect(bound.name).toBeUndefined(); }); + test("AMD refuses where a surplus parameter would pair with the new dependency", async () => { + const spec = new RecipeSpec(); + const bound: {name?: string} = {}; + spec.recipe = fromVisitor(rebind("sap/ui/core/Element", bound)); + await spec.rewriteRun(javascript( + `sap.ui.define(["sap/m/Button"], function (Button, Extra) { target(); });` + )); + expect(bound.name).toBeUndefined(); + }); + test("a binding nothing goes on to reference is not written", async () => { const spec = new RecipeSpec(); const bound: {name?: string} = {}; @@ -419,3 +429,14 @@ describe("maybeRemoveImport on an AMD block", () => { )); }); }); + +describe("maybeRemoveImport on an ESM file", () => { + test("removes the import; the AMD lane it also queues finds no block to act on", async () => { + const spec = new RecipeSpec(); + spec.recipe = fromVisitor(dropModule("sap/m/Button")); + await spec.rewriteRun(typescript( + `import Button from "sap/m/Button";\n\nconsole.log(1);`, + `console.log(1);` + )); + }); +}); From 4491950b490670c863ed06af34da8f55369ee461 Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Thu, 27 Aug 2026 17:03:40 +0200 Subject: [PATCH 13/41] JavaScript: cover the expression-bodied AMD arrow factory the count-mismatch fix quietly repaired MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../rewrite/src/javascript/bind-module.ts | 7 ++++--- .../rewrite/test/javascript/bind-module.test.ts | 11 +++++++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/rewrite-javascript/rewrite/src/javascript/bind-module.ts b/rewrite-javascript/rewrite/src/javascript/bind-module.ts index 646f041a54..c24807e239 100644 --- a/rewrite-javascript/rewrite/src/javascript/bind-module.ts +++ b/rewrite-javascript/rewrite/src/javascript/bind-module.ts @@ -409,9 +409,10 @@ export class AddAmdDependency

extends JavaScriptVisitor

{ // Nothing referenced the binding, so the caller asked and then did not use the answer. return visited; } - // withDependency re-checks the parameter gap against the tree as it stands now: another - // edit in the same visit that drops a factory parameter can reopen a gap bindAmd's own - // check already cleared, and returning its `undefined` here would delete this node. + // withDependency re-checks the parameter/dependency count against the tree as it stands + // now: another edit in the same visit that drops or adds a factory parameter can reopen + // a mismatch bindAmd's own check already cleared, and returning its `undefined` here + // would delete this node. return withDependency(visited, block, this.module, this.binding) ?? visited; } } diff --git a/rewrite-javascript/rewrite/test/javascript/bind-module.test.ts b/rewrite-javascript/rewrite/test/javascript/bind-module.test.ts index 1001bc1307..de89b73c11 100644 --- a/rewrite-javascript/rewrite/test/javascript/bind-module.test.ts +++ b/rewrite-javascript/rewrite/test/javascript/bind-module.test.ts @@ -183,6 +183,17 @@ describe("bindModule", () => { expect(bound.name).toBe("Element"); }); + test("AMD appends to an expression-bodied arrow factory same as a block-bodied one", async () => { + const spec = new RecipeSpec(); + const bound: {name?: string} = {}; + spec.recipe = fromVisitor(rebind("sap/ui/core/Element", bound)); + await spec.rewriteRun(javascript( + `sap.ui.define(["a/B"], (B) => target());`, + `sap.ui.define(["a/B", "sap/ui/core/Element"], (B, Element) => Element.target());` + )); + expect(bound.name).toBe("Element"); + }); + test("AMD reuses an existing dependency's parameter and edits nothing on that lane", async () => { const spec = new RecipeSpec(); const bound: {name?: string} = {}; From cd6b7138fc1535a559efc9507820427a0c49f070 Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Thu, 27 Aug 2026 17:11:29 +0200 Subject: [PATCH 14/41] JavaScript: drop AMD bindings a rewrite left unreferenced --- .../rewrite/src/javascript/bind-module.ts | 76 ++++++++++++++++++- .../test/javascript/bind-module.test.ts | 43 ++++++++++- 2 files changed, 117 insertions(+), 2 deletions(-) diff --git a/rewrite-javascript/rewrite/src/javascript/bind-module.ts b/rewrite-javascript/rewrite/src/javascript/bind-module.ts index c24807e239..7a91c97be8 100644 --- a/rewrite-javascript/rewrite/src/javascript/bind-module.ts +++ b/rewrite-javascript/rewrite/src/javascript/bind-module.ts @@ -21,7 +21,8 @@ import {ExecutionContext} from "../execution"; import {UUID} from "../uuid"; import {maybeAddImport, moduleNameOf, QuoteChar} from "./add-import"; import { - AmdBlock, amdBlockOf, DEFAULT_AMD_CALLEES, dependencyNames, elementsOf, parameterNames, present, withDependency + AmdBlock, amdBlockOf, DEFAULT_AMD_CALLEES, dependencyNames, elementsOf, parameterNames, present, withDependency, + withoutDependencyAt } from "./amd"; export type EsmBindingForm = "default" | "namespace"; @@ -416,3 +417,76 @@ export class AddAmdDependency

extends JavaScriptVisitor

{ return withDependency(visited, block, this.module, this.binding) ?? visited; } } + +/** + * Drops AMD bindings a rewrite left unreferenced. A binding already unused before the rewrite + * stays: it is loaded for its side effects, so removing it would change what the module loads, + * not just what gets called. This can't defer onto `visitor.afterVisit` like the rest of this + * module's edits do — it needs the tree as it stood before the rewrite, which a deferred visitor + * never sees. + */ +export async function removeNewlyUnusedBindings( + before: JS.CompilationUnit, + after: JS.CompilationUnit, + ctx: ExecutionContext, + options?: BindModuleOptions +): Promise { + const callees = calleesOf(options); + const usedBeforeByBlock = new Map>(); + const collector = new class extends JavaScriptVisitor { + override async visitMethodInvocation(m: J.MethodInvocation, c: ExecutionContext) { + const block = amdBlockOf(m, callees); + if (block !== undefined) { + usedBeforeByBlock.set(m.id, await usedBindings(block)); + } + return super.visitMethodInvocation(m, c); + } + }; + await collector.visit(before, ctx); + if (usedBeforeByBlock.size === 0) { + return after; + } + + const sweeper = new class extends JavaScriptVisitor { + override async visitMethodInvocation(m: J.MethodInvocation, c: ExecutionContext): Promise { + let call = await super.visitMethodInvocation(m, c) as J.MethodInvocation; + const usedBefore = usedBeforeByBlock.get(call.id); + if (usedBefore === undefined) { + return call; + } + // A removal shifts the indices of the dependencies after it, so the block and its + // parameter names are re-derived from `call` on every pass rather than cached. + for (; ;) { + const block = amdBlockOf(call, callees); + if (block === undefined) { + return call; + } + const bindings = parameterNames(block); + let goneIndex = -1; + for (let i = 0; i < bindings.length; i++) { + const binding = bindings[i]; + if (binding !== undefined && usedBefore.has(binding) && !(await references(block, binding, true))) { + goneIndex = i; + break; + } + } + if (goneIndex < 0) { + return call; + } + call = withoutDependencyAt(call, block, goneIndex); + } + } + }; + return await sweeper.visit(after, ctx) as JS.CompilationUnit; +} + +/** The block's parameter names that its body actually references. */ +async function usedBindings(block: AmdBlock): Promise> { + const used = new Set(); + for (const binding of parameterNames(block)) { + if (binding !== undefined && await references(block, binding, false)) { + used.add(binding); + } + } + return used; +} diff --git a/rewrite-javascript/rewrite/test/javascript/bind-module.test.ts b/rewrite-javascript/rewrite/test/javascript/bind-module.test.ts index de89b73c11..a41eb8d6e3 100644 --- a/rewrite-javascript/rewrite/test/javascript/bind-module.test.ts +++ b/rewrite-javascript/rewrite/test/javascript/bind-module.test.ts @@ -1,11 +1,12 @@ import {fromVisitor, RecipeSpec} from "../../src/test"; import { JavaScriptVisitor, JS, javascript, typescript, moduleBindings, isAmdBlock, ModuleBindings, bindModule, - maybeRemoveImport + maybeRemoveImport, removeNewlyUnusedBindings } from "../../src/javascript"; import {emptySpace, J, rightPadded} from "../../src/java"; import {emptyMarkers} from "../../src/markers"; import {randomId} from "../../src/uuid"; +import {ExecutionContext} from "../../src"; function captureBindings(seen: {moduleSystem?: string, module?: string, binding?: string}, localName: string = "Button", moduleName: string = "sap/m/Button") { @@ -451,3 +452,43 @@ describe("maybeRemoveImport on an ESM file", () => { )); }); }); + +function rewriteThenSweep() { + return new class extends JavaScriptVisitor { + override async visitJsCompilationUnit(cu: JS.CompilationUnit, ctx: ExecutionContext): Promise { + const rewritten = await super.visitJsCompilationUnit(cu, ctx) as JS.CompilationUnit; + return removeNewlyUnusedBindings(cu, rewritten, ctx); + } + + override async visitMethodInvocation(m: J.MethodInvocation, ctx: ExecutionContext): Promise { + // `Old.f()` becomes `kept.f()`, so nothing names `Old` afterwards. + const select = m.select?.element; + if (select !== undefined && select.kind === J.Kind.Identifier && + (select as J.Identifier).simpleName === "Old") { + const kept: J.Identifier = {...(select as J.Identifier), simpleName: "kept"}; + const renamed: J.MethodInvocation = {...m, select: {...m.select!, element: kept}}; + return renamed; + } + return super.visitMethodInvocation(m, ctx); + } + }; +} + +describe("removeNewlyUnusedBindings", () => { + test("a binding a rewrite stopped using goes", async () => { + const spec = new RecipeSpec(); + spec.recipe = fromVisitor(rewriteThenSweep()); + await spec.rewriteRun(javascript( + `sap.ui.define(["a/Old", "a/Kept"], function (Old, kept) { Old.f(); });`, + `sap.ui.define(["a/Kept"], function (kept) { kept.f(); });` + )); + }); + + test("a binding that was already unused stays, being loaded for its side effects", async () => { + const spec = new RecipeSpec(); + spec.recipe = fromVisitor(rewriteThenSweep()); + await spec.rewriteRun(javascript( + `sap.ui.define(["a/Side", "a/Kept"], function (side, kept) { kept.f(); });` + )); + }); +}); From ab3b059a6bb8d81db2881bf4e55777de4fb67f93 Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Thu, 27 Aug 2026 17:29:54 +0200 Subject: [PATCH 15/41] JavaScript: single-walk namesUsed for removeNewlyUnusedBindings, plus 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. --- .../rewrite/src/javascript/bind-module.ts | 59 +++++++++++++------ .../test/javascript/bind-module.test.ts | 29 +++++---- 2 files changed, 59 insertions(+), 29 deletions(-) diff --git a/rewrite-javascript/rewrite/src/javascript/bind-module.ts b/rewrite-javascript/rewrite/src/javascript/bind-module.ts index 7a91c97be8..2f5bdb66e4 100644 --- a/rewrite-javascript/rewrite/src/javascript/bind-module.ts +++ b/rewrite-javascript/rewrite/src/javascript/bind-module.ts @@ -370,6 +370,27 @@ export async function references(block: AmdBlock, name: string, missingBodyAnswe return found; } +/** + * Every name the factory body references, one walk for the whole block rather than one per + * name. `missingBodyAnswer` means what it means on `references`: the answer `.has` gives, for + * every name, when the factory has no body to walk. + */ +export async function namesUsed(block: AmdBlock, missingBodyAnswer: boolean): Promise<{has(name: string): boolean}> { + const body = bodyOf(block); + if (body === undefined) { + return {has: () => missingBodyAnswer}; + } + const names = new Set(); + const collector = new class extends JavaScriptVisitor { + override async visitIdentifier(id: J.Identifier, c: ExecutionContext) { + names.add(id.simpleName); + return super.visitIdentifier(id, c); + } + }; + await collector.visit(body, new ExecutionContext()); + return names; +} + /** * Applies the dependency a `bindModule` call settled on. The block is found by id: its caller * has already emitted a reference to the binding, so a block that cannot be found is an error @@ -422,8 +443,8 @@ export class AddAmdDependency

extends JavaScriptVisitor

{ * Drops AMD bindings a rewrite left unreferenced. A binding already unused before the rewrite * stays: it is loaded for its side effects, so removing it would change what the module loads, * not just what gets called. This can't defer onto `visitor.afterVisit` like the rest of this - * module's edits do — it needs the tree as it stood before the rewrite, which a deferred visitor - * never sees. + * module's edits — it needs the tree as it stood before the rewrite. Blocks are matched by the + * call's id, so one a rewrite rebuilds rather than edits is silently left alone. */ export async function removeNewlyUnusedBindings( before: JS.CompilationUnit, @@ -451,40 +472,40 @@ export async function removeNewlyUnusedBindings( override async visitMethodInvocation(m: J.MethodInvocation, c: ExecutionContext): Promise { let call = await super.visitMethodInvocation(m, c) as J.MethodInvocation; const usedBefore = usedBeforeByBlock.get(call.id); - if (usedBefore === undefined) { + let block = usedBefore === undefined ? undefined : amdBlockOf(call, callees); + if (usedBefore === undefined || block === undefined) { return call; } - // A removal shifts the indices of the dependencies after it, so the block and its - // parameter names are re-derived from `call` on every pass rather than cached. + // withoutDependencyAt only edits the dependency array and parameter list, never the + // body, so which names it references can't change between removals. + const usedNow = await namesUsed(block, true); for (; ;) { - const block = amdBlockOf(call, callees); - if (block === undefined) { - return call; - } const bindings = parameterNames(block); - let goneIndex = -1; - for (let i = 0; i < bindings.length; i++) { - const binding = bindings[i]; - if (binding !== undefined && usedBefore.has(binding) && !(await references(block, binding, true))) { - goneIndex = i; - break; - } - } + const goneIndex = bindings.findIndex(binding => + binding !== undefined && usedBefore.has(binding) && !usedNow.has(binding)); if (goneIndex < 0) { return call; } call = withoutDependencyAt(call, block, goneIndex); + // A removal shifts the indices of the dependencies after it, so the block is + // re-derived from `call` rather than reused with stale offsets. + const next = amdBlockOf(call, callees); + if (next === undefined) { + return call; + } + block = next; } } }; return await sweeper.visit(after, ctx) as JS.CompilationUnit; } -/** The block's parameter names that its body actually references. */ +/** The block's parameter names that its body actually references, from a single walk of it. */ async function usedBindings(block: AmdBlock): Promise> { + const usedNames = await namesUsed(block, false); const used = new Set(); for (const binding of parameterNames(block)) { - if (binding !== undefined && await references(block, binding, false)) { + if (binding !== undefined && usedNames.has(binding)) { used.add(binding); } } diff --git a/rewrite-javascript/rewrite/test/javascript/bind-module.test.ts b/rewrite-javascript/rewrite/test/javascript/bind-module.test.ts index a41eb8d6e3..759983fb77 100644 --- a/rewrite-javascript/rewrite/test/javascript/bind-module.test.ts +++ b/rewrite-javascript/rewrite/test/javascript/bind-module.test.ts @@ -453,7 +453,8 @@ describe("maybeRemoveImport on an ESM file", () => { }); }); -function rewriteThenSweep() { +/** Renames a call's `select` identifier per `renames`, then sweeps whatever the rename orphaned. */ +function renameThenSweep(renames: Record) { return new class extends JavaScriptVisitor { override async visitJsCompilationUnit(cu: JS.CompilationUnit, ctx: ExecutionContext): Promise { const rewritten = await super.visitJsCompilationUnit(cu, ctx) as JS.CompilationUnit; @@ -461,15 +462,14 @@ function rewriteThenSweep() { } override async visitMethodInvocation(m: J.MethodInvocation, ctx: ExecutionContext): Promise { - // `Old.f()` becomes `kept.f()`, so nothing names `Old` afterwards. const select = m.select?.element; - if (select !== undefined && select.kind === J.Kind.Identifier && - (select as J.Identifier).simpleName === "Old") { - const kept: J.Identifier = {...(select as J.Identifier), simpleName: "kept"}; - const renamed: J.MethodInvocation = {...m, select: {...m.select!, element: kept}}; - return renamed; + const to = select?.kind === J.Kind.Identifier ? renames[(select as J.Identifier).simpleName] : undefined; + if (to === undefined) { + return super.visitMethodInvocation(m, ctx); } - return super.visitMethodInvocation(m, ctx); + const renamedSelect: J.Identifier = {...(select as J.Identifier), simpleName: to}; + const renamed: J.MethodInvocation = {...m, select: {...m.select!, element: renamedSelect}}; + return renamed; } }; } @@ -477,7 +477,7 @@ function rewriteThenSweep() { describe("removeNewlyUnusedBindings", () => { test("a binding a rewrite stopped using goes", async () => { const spec = new RecipeSpec(); - spec.recipe = fromVisitor(rewriteThenSweep()); + spec.recipe = fromVisitor(renameThenSweep({Old: "kept"})); await spec.rewriteRun(javascript( `sap.ui.define(["a/Old", "a/Kept"], function (Old, kept) { Old.f(); });`, `sap.ui.define(["a/Kept"], function (kept) { kept.f(); });` @@ -486,9 +486,18 @@ describe("removeNewlyUnusedBindings", () => { test("a binding that was already unused stays, being loaded for its side effects", async () => { const spec = new RecipeSpec(); - spec.recipe = fromVisitor(rewriteThenSweep()); + spec.recipe = fromVisitor(renameThenSweep({Old: "kept"})); await spec.rewriteRun(javascript( `sap.ui.define(["a/Side", "a/Kept"], function (side, kept) { kept.f(); });` )); }); + + test("removing two non-adjacent bindings still pairs each survivor with its own dependency", async () => { + const spec = new RecipeSpec(); + spec.recipe = fromVisitor(renameThenSweep({B: "A", D: "C"})); + await spec.rewriteRun(javascript( + `sap.ui.define(["a/A", "a/B", "a/C", "a/D"], function (A, B, C, D) { A.f(); B.f(); C.f(); D.f(); });`, + `sap.ui.define(["a/A", "a/C"], function (A, C) { A.f(); A.f(); C.f(); C.f(); });` + )); + }); }); From bdc531bdd9a9c2eb8de111bd232b097834727362 Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Thu, 27 Aug 2026 18:22:15 +0200 Subject: [PATCH 16/41] JavaScript: fix AMD binding-module correctness gaps from final review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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, 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. --- .../rewrite/src/javascript/amd.ts | 20 ++++++- .../rewrite/src/javascript/bind-module.ts | 55 +++++++++++++++---- .../rewrite/src/javascript/index.ts | 7 ++- .../rewrite/src/javascript/remove-import.ts | 5 +- .../rewrite/test/javascript/amd.test.ts | 26 +++++++++ .../test/javascript/bind-module.test.ts | 48 ++++++++++++---- 6 files changed, 134 insertions(+), 27 deletions(-) diff --git a/rewrite-javascript/rewrite/src/javascript/amd.ts b/rewrite-javascript/rewrite/src/javascript/amd.ts index 9c9801d367..bf72836b91 100644 --- a/rewrite-javascript/rewrite/src/javascript/amd.ts +++ b/rewrite-javascript/rewrite/src/javascript/amd.ts @@ -291,6 +291,17 @@ function splitTrailingComments(after: J.Space): {comments: J.Space, whitespace: {comments: after, whitespace: emptySpace}; } +/** + * Splits a leading prefix, mirroring `splitTrailingComments`: a comment documents the entry it + * precedes and is dropped along with it, while plain whitespace only ever positioned that entry + * after the opening bracket or a comma, so it travels to whichever entry takes its place. + */ +function splitLeadingComments(prefix: J.Space): {comments: J.Space, whitespace: J.Space} { + return prefix.comments.length === 0 ? + {comments: {...prefix, whitespace: ""}, whitespace: space(prefix.whitespace)} : + {comments: prefix, whitespace: emptySpace}; +} + /** * Removes the entry at `index`. The first entry carries no separating whitespace and the * last carries the whitespace before the closing bracket, so removing either hands its @@ -310,7 +321,14 @@ function removeEntry( return remaining; } if (index === 0) { - remaining[0] = {...remaining[0], element: slot.withPrefix(remaining[0].element, slot.prefixOf(removed.element))}; + // Spread the survivor's own prefix rather than replacing it outright, so a comment it + // already carries survives; only its plain whitespace is replaced. + const leading = splitLeadingComments(slot.prefixOf(removed.element)); + const survivor = remaining[0]; + remaining[0] = { + ...survivor, + element: slot.withPrefix(survivor.element, {...slot.prefixOf(survivor.element), whitespace: leading.whitespace.whitespace}) + }; } else if (index === remaining.length) { const last = {...remaining[index - 1], after: removed.after}; remaining[index - 1] = moveTrailingComma(removed, last).to; diff --git a/rewrite-javascript/rewrite/src/javascript/bind-module.ts b/rewrite-javascript/rewrite/src/javascript/bind-module.ts index 2f5bdb66e4..eceddb290d 100644 --- a/rewrite-javascript/rewrite/src/javascript/bind-module.ts +++ b/rewrite-javascript/rewrite/src/javascript/bind-module.ts @@ -60,7 +60,7 @@ export function calleesOf(options?: BindModuleOptions): readonly string[] { return callee === undefined ? DEFAULT_AMD_CALLEES : typeof callee === "string" ? [callee] : callee; } -export function isAmdBlock(node: J, options?: BindModuleOptions): boolean { +export function isAmdBlock(node: J, options?: Pick): boolean { return node.kind === J.Kind.MethodInvocation && amdBlockOf(node as J.MethodInvocation, calleesOf(options)) !== undefined; } @@ -97,7 +97,7 @@ export function compilationUnitOf(visitor: JavaScriptVisitor): JS.Compilati export function moduleBindings( visitor: JavaScriptVisitor, - options?: BindModuleOptions + options?: Pick ): ModuleBindings { const amd = enclosingAmdBlock(visitor, options); if (amd !== undefined) { @@ -132,6 +132,9 @@ interface ModuleObjectBinding { /** Whether the file binds its modules with `require`, which decides whether a create is possible. */ function isCommonJs(cu: JS.CompilationUnit): boolean { + if (cu.sourcePath.endsWith(".cjs")) { + return true; + } let requires = false; for (const stmt of cu.statements) { if (stmt.element?.kind === JS.Kind.Import) { @@ -155,7 +158,9 @@ function requiredModule(statement: J | undefined): string | undefined { return undefined; } const call = initializer as J.MethodInvocation; - if (call.name.simpleName !== "require") { + // `obj.require('x')` selects a method rather than loading a module, matching add-import.ts's + // own `requiredModuleOf`. + if (call.select || call.name.simpleName !== "require") { return undefined; } const argument = present(call.arguments.elements)[0]?.element; @@ -317,9 +322,7 @@ async function declaredNames(block: AmdBlock, visitor: JavaScriptVisitor): const collector = new class extends JavaScriptVisitor { override async visitVariableDeclarations(v: J.VariableDeclarations, c: ExecutionContext) { for (const variable of v.variables) { - if (isIdentifier(variable.element.name)) { - names.push((variable.element.name as J.Identifier).simpleName); - } + names.push(...bindingNames(variable.element.name)); } return super.visitVariableDeclarations(v, c); } @@ -338,6 +341,31 @@ async function declaredNames(block: AmdBlock, visitor: JavaScriptVisitor): return names; } +/** + * Every identifier a binding pattern introduces, recursing through nested destructuring so + * `const {a: {b}} = m` yields `b`. A plain identifier introduces itself. + */ +function bindingNames(pattern: J | undefined): string[] { + if (isIdentifier(pattern)) { + return [pattern.simpleName]; + } + const elements = pattern?.kind === JS.Kind.ObjectBindingPattern + ? (pattern as JS.ObjectBindingPattern).bindings.elements + : pattern?.kind === JS.Kind.ArrayBindingPattern + ? (pattern as JS.ArrayBindingPattern).elements.elements + : undefined; + if (elements === undefined) { + return []; + } + const names: string[] = []; + for (const elem of elements) { + if (elem.element?.kind === JS.Kind.BindingElement) { + names.push(...bindingNames((elem.element as JS.BindingElement).name)); + } + } + return names; +} + /** The factory's body: a `J.Block` for `function(){}` and most arrows, or the bare expression for `() => expr`. */ export function bodyOf(block: AmdBlock): J | undefined { return block.factory.kind === J.Kind.MethodDeclaration ? @@ -433,9 +461,16 @@ export class AddAmdDependency

extends JavaScriptVisitor

{ } // withDependency re-checks the parameter/dependency count against the tree as it stands // now: another edit in the same visit that drops or adds a factory parameter can reopen - // a mismatch bindAmd's own check already cleared, and returning its `undefined` here - // would delete this node. - return withDependency(visited, block, this.module, this.binding) ?? visited; + // a mismatch bindAmd's own check already cleared. The caller has already emitted a + // reference to `this.binding`, so — as for the missing-block case above — a mismatch + // here is an error, not a silently dropped dependency. + const dependency = withDependency(visited, block, this.module, this.binding); + if (dependency === undefined) { + throw new Error( + `AMD block ${this.blockId} no longer has matching dependency and parameter counts, ` + + `so '${this.module}' could not be declared for '${this.binding}', which was reported bound`); + } + return dependency; } } @@ -446,7 +481,7 @@ export class AddAmdDependency

extends JavaScriptVisitor

{ * module's edits — it needs the tree as it stood before the rewrite. Blocks are matched by the * call's id, so one a rewrite rebuilds rather than edits is silently left alone. */ -export async function removeNewlyUnusedBindings( +export async function removeNewlyUnusedAmdBindings( before: JS.CompilationUnit, after: JS.CompilationUnit, ctx: ExecutionContext, diff --git a/rewrite-javascript/rewrite/src/javascript/index.ts b/rewrite-javascript/rewrite/src/javascript/index.ts index ad503c4b7b..a6ebf1d39d 100644 --- a/rewrite-javascript/rewrite/src/javascript/index.ts +++ b/rewrite-javascript/rewrite/src/javascript/index.ts @@ -33,8 +33,11 @@ export * from "./project-parser"; export * from "./scope"; export * from "./add-import"; -export * from "./amd"; -export * from "./bind-module"; +// AMD mechanics `recipes-ui5` builds on directly, beyond the `bindModule` surface below. +export type {AmdBlock} from "./amd"; +export {DEFAULT_AMD_CALLEES, amdBlockOf, dependencyNames, parameterNames, withDependency, withoutDependencyAt} from "./amd"; +export type {EsmBindingForm, BindModuleOptions, ModuleBindings} from "./bind-module"; +export {bindModule, moduleBindings, isAmdBlock, removeNewlyUnusedAmdBindings} from "./bind-module"; export * from "./remove-import"; export * from "./remove-amd-dependency"; export * from "./cleanup/index"; diff --git a/rewrite-javascript/rewrite/src/javascript/remove-import.ts b/rewrite-javascript/rewrite/src/javascript/remove-import.ts index f72f4f71aa..aa5deca23c 100644 --- a/rewrite-javascript/rewrite/src/javascript/remove-import.ts +++ b/rewrite-javascript/rewrite/src/javascript/remove-import.ts @@ -41,8 +41,9 @@ export function maybeRemoveImport(visitor: JavaScriptVisitor, module: strin } } visitor.afterVisit.push(new RemoveImport(module, member)); - // Whichever lane the file turns out to use, the other visitor finds nothing to do, so the - // caller need not know which one it is in. + // Both queue unconditionally so the caller need not know which lane the file uses: each + // visitor removes whatever matching construct it finds, ESM import or AMD dependency, and a + // file with both gets both removed. visitor.afterVisit.push(new RemoveAmdDependency(module)); } diff --git a/rewrite-javascript/rewrite/test/javascript/amd.test.ts b/rewrite-javascript/rewrite/test/javascript/amd.test.ts index 592e8f4158..16c3a0859a 100644 --- a/rewrite-javascript/rewrite/test/javascript/amd.test.ts +++ b/rewrite-javascript/rewrite/test/javascript/amd.test.ts @@ -201,4 +201,30 @@ describe("withoutDependencyAt", () => { const block = amdBlockOf(call)!; expect(dependencyNames(amdBlockOf(withoutDependencyAt(call, block, 2))!)).toEqual(["a/B", "c/D"]); }); + + function removeFirstDependency() { + return new class extends JavaScriptVisitor { + override async visitMethodInvocation(m: J.MethodInvocation, p: any): Promise { + const block = amdBlockOf(m); + return block === undefined ? super.visitMethodInvocation(m, p) : + withoutDependencyAt(m, block, 0); + } + }; + } + + test("removing the first entry moves no comment onto the survivor and drops none of its own", async () => { + const relabeled = new RecipeSpec(); + relabeled.recipe = fromVisitor(removeFirstDependency()); + await relabeled.rewriteRun(javascript( + `sap.ui.define([/* keep me */ "a/B", "c/D"], function (B, D) {});`, + `sap.ui.define(["c/D"], function (D) {});` + )); + + const dropped = new RecipeSpec(); + dropped.recipe = fromVisitor(removeFirstDependency()); + await dropped.rewriteRun(javascript( + `sap.ui.define(["a/B", /* about D */ "c/D"], function (B, D) {});`, + `sap.ui.define([/* about D */ "c/D"], function (D) {});` + )); + }); }); diff --git a/rewrite-javascript/rewrite/test/javascript/bind-module.test.ts b/rewrite-javascript/rewrite/test/javascript/bind-module.test.ts index 759983fb77..8d57fe11bb 100644 --- a/rewrite-javascript/rewrite/test/javascript/bind-module.test.ts +++ b/rewrite-javascript/rewrite/test/javascript/bind-module.test.ts @@ -1,7 +1,7 @@ import {fromVisitor, RecipeSpec} from "../../src/test"; import { JavaScriptVisitor, JS, javascript, typescript, moduleBindings, isAmdBlock, ModuleBindings, bindModule, - maybeRemoveImport, removeNewlyUnusedBindings + maybeRemoveImport, removeNewlyUnusedAmdBindings } from "../../src/javascript"; import {emptySpace, J, rightPadded} from "../../src/java"; import {emptyMarkers} from "../../src/markers"; @@ -48,6 +48,22 @@ describe("moduleBindings", () => { expect(seen.moduleSystem).toBe("esm"); }); + test("a selected require, unlike a bare one, does not read as a CommonJS binding", async () => { + const spec = new RecipeSpec(); + const seen: {moduleSystem?: string} = {}; + spec.recipe = fromVisitor(captureBindings(seen)); + await spec.rewriteRun(javascript(`const other = foo.require("a/Other");`)); + expect(seen.moduleSystem).toBe("esm"); + }); + + test("a .cjs file reads as CommonJS even with no require call in it yet", async () => { + const spec = new RecipeSpec(); + const seen: {moduleSystem?: string} = {}; + spec.recipe = fromVisitor(captureBindings(seen)); + await spec.rewriteRun({...javascript(`target();`), path: "module.cjs"}); + expect(seen.moduleSystem).toBe("commonjs"); + }); + test("an AMD block's parameters bind positionally to its dependencies", async () => { const spec = new RecipeSpec(); const seen: {moduleSystem?: string, module?: string, binding?: string} = {}; @@ -217,6 +233,17 @@ describe("bindModule", () => { expect(bound.name).toBe("Element_1"); }); + test("AMD avoids a name a destructuring pattern binds", async () => { + const spec = new RecipeSpec(); + const bound: {name?: string} = {}; + spec.recipe = fromVisitor(rebind("sap/ui/core/Element", bound)); + await spec.rewriteRun(javascript( + `sap.ui.define([], function () { const {Element} = window; target(); });`, + `sap.ui.define(["sap/ui/core/Element"], function (Element_1) { const {Element} = window; Element_1.target(); });` + )); + expect(bound.name).toBe("Element_1"); + }); + test("AMD refuses where a parameter would pair with the wrong dependency", async () => { const spec = new RecipeSpec(); const bound: {name?: string} = {}; @@ -314,14 +341,13 @@ describe("bindModule", () => { ))).rejects.toThrow(/No AMD block/); }); - test("a parameter dropped elsewhere in the same visit does not delete the block", async () => { + test("a parameter dropped elsewhere in the same visit rejects the queued dependency", async () => { const spec = new RecipeSpec(); - const bound: {name?: string} = {}; spec.recipe = fromVisitor(new class extends JavaScriptVisitor { override async visitMethodInvocation(m: J.MethodInvocation, p: any): Promise { if (m.name.simpleName === "target") { - bound.name = await bindModule(this, "sap/ui/core/Element"); - return bound.name === undefined ? m : withReference(m, bound.name); + const name = await bindModule(this, "sap/ui/core/Element"); + return name === undefined ? m : withReference(m, name); } const visited = await super.visitMethodInvocation(m, p) as J.MethodInvocation; if (!isAmdBlock(visited)) { @@ -344,11 +370,9 @@ describe("bindModule", () => { return withStrippedFactory; } }); - await spec.rewriteRun(javascript( - `sap.ui.define(["a/B"], function (B) { target(); });`, - `sap.ui.define(["a/B"], function () { Element.target(); });` - )); - expect(bound.name).toBe("Element"); + await expect(spec.rewriteRun(javascript( + `sap.ui.define(["a/B"], function (B) { target(); });` + ))).rejects.toThrow(/no longer has matching dependency and parameter counts/); }); test("ESM creates a default import and reports its name", async () => { @@ -458,7 +482,7 @@ function renameThenSweep(renames: Record) { return new class extends JavaScriptVisitor { override async visitJsCompilationUnit(cu: JS.CompilationUnit, ctx: ExecutionContext): Promise { const rewritten = await super.visitJsCompilationUnit(cu, ctx) as JS.CompilationUnit; - return removeNewlyUnusedBindings(cu, rewritten, ctx); + return removeNewlyUnusedAmdBindings(cu, rewritten, ctx); } override async visitMethodInvocation(m: J.MethodInvocation, ctx: ExecutionContext): Promise { @@ -474,7 +498,7 @@ function renameThenSweep(renames: Record) { }; } -describe("removeNewlyUnusedBindings", () => { +describe("removeNewlyUnusedAmdBindings", () => { test("a binding a rewrite stopped using goes", async () => { const spec = new RecipeSpec(); spec.recipe = fromVisitor(renameThenSweep({Old: "kept"})); From da758658551e0c42351472871b34eb5dfa7871ed Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Thu, 27 Aug 2026 18:26:16 +0200 Subject: [PATCH 17/41] JavaScript: cover nested destructuring in the AMD declaredNames fix 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. --- .../rewrite/test/javascript/bind-module.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/rewrite-javascript/rewrite/test/javascript/bind-module.test.ts b/rewrite-javascript/rewrite/test/javascript/bind-module.test.ts index 8d57fe11bb..0fe15a6936 100644 --- a/rewrite-javascript/rewrite/test/javascript/bind-module.test.ts +++ b/rewrite-javascript/rewrite/test/javascript/bind-module.test.ts @@ -244,6 +244,17 @@ describe("bindModule", () => { expect(bound.name).toBe("Element_1"); }); + test("AMD avoids a name a nested destructuring pattern binds", async () => { + const spec = new RecipeSpec(); + const bound: {name?: string} = {}; + spec.recipe = fromVisitor(rebind("sap/ui/core/Deep", bound)); + await spec.rewriteRun(javascript( + `sap.ui.define([], function () { const [{Deep}] = window; target(); });`, + `sap.ui.define(["sap/ui/core/Deep"], function (Deep_1) { const [{Deep}] = window; Deep_1.target(); });` + )); + expect(bound.name).toBe("Deep_1"); + }); + test("AMD refuses where a parameter would pair with the wrong dependency", async () => { const spec = new RecipeSpec(); const bound: {name?: string} = {}; From c61a9dd8bbcbbb9dd36f946410e07e09b2ac9de8 Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Thu, 27 Aug 2026 18:36:21 +0200 Subject: [PATCH 18/41] JavaScript: handle rest elements in declaredNames; trim removeEntry's dead comment split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../rewrite/src/javascript/amd.ts | 17 ++++++++--------- .../rewrite/src/javascript/bind-module.ts | 3 +++ .../rewrite/test/javascript/bind-module.test.ts | 11 +++++++++++ 3 files changed, 22 insertions(+), 9 deletions(-) diff --git a/rewrite-javascript/rewrite/src/javascript/amd.ts b/rewrite-javascript/rewrite/src/javascript/amd.ts index bf72836b91..41df9794f6 100644 --- a/rewrite-javascript/rewrite/src/javascript/amd.ts +++ b/rewrite-javascript/rewrite/src/javascript/amd.ts @@ -292,14 +292,13 @@ function splitTrailingComments(after: J.Space): {comments: J.Space, whitespace: } /** - * Splits a leading prefix, mirroring `splitTrailingComments`: a comment documents the entry it - * precedes and is dropped along with it, while plain whitespace only ever positioned that entry - * after the opening bracket or a comma, so it travels to whichever entry takes its place. + * The plain-whitespace part of a leading prefix whose entry is about to be removed. A comment in + * that prefix documents the entry it precedes and is dropped along with it; the whitespace only + * ever positioned that entry after the opening bracket or a comma, so it's what travels to + * whichever entry takes its place. */ -function splitLeadingComments(prefix: J.Space): {comments: J.Space, whitespace: J.Space} { - return prefix.comments.length === 0 ? - {comments: {...prefix, whitespace: ""}, whitespace: space(prefix.whitespace)} : - {comments: prefix, whitespace: emptySpace}; +function leadingWhitespaceOf(prefix: J.Space): string { + return prefix.comments.length === 0 ? prefix.whitespace : ""; } /** @@ -323,11 +322,11 @@ function removeEntry( if (index === 0) { // Spread the survivor's own prefix rather than replacing it outright, so a comment it // already carries survives; only its plain whitespace is replaced. - const leading = splitLeadingComments(slot.prefixOf(removed.element)); + const whitespace = leadingWhitespaceOf(slot.prefixOf(removed.element)); const survivor = remaining[0]; remaining[0] = { ...survivor, - element: slot.withPrefix(survivor.element, {...slot.prefixOf(survivor.element), whitespace: leading.whitespace.whitespace}) + element: slot.withPrefix(survivor.element, {...slot.prefixOf(survivor.element), whitespace}) }; } else if (index === remaining.length) { const last = {...remaining[index - 1], after: removed.after}; diff --git a/rewrite-javascript/rewrite/src/javascript/bind-module.ts b/rewrite-javascript/rewrite/src/javascript/bind-module.ts index eceddb290d..5efa487cd4 100644 --- a/rewrite-javascript/rewrite/src/javascript/bind-module.ts +++ b/rewrite-javascript/rewrite/src/javascript/bind-module.ts @@ -349,6 +349,9 @@ function bindingNames(pattern: J | undefined): string[] { if (isIdentifier(pattern)) { return [pattern.simpleName]; } + if (pattern?.kind === JS.Kind.Spread) { + return bindingNames((pattern as JS.Spread).expression); + } const elements = pattern?.kind === JS.Kind.ObjectBindingPattern ? (pattern as JS.ObjectBindingPattern).bindings.elements : pattern?.kind === JS.Kind.ArrayBindingPattern diff --git a/rewrite-javascript/rewrite/test/javascript/bind-module.test.ts b/rewrite-javascript/rewrite/test/javascript/bind-module.test.ts index 0fe15a6936..c2244e2a92 100644 --- a/rewrite-javascript/rewrite/test/javascript/bind-module.test.ts +++ b/rewrite-javascript/rewrite/test/javascript/bind-module.test.ts @@ -255,6 +255,17 @@ describe("bindModule", () => { expect(bound.name).toBe("Deep_1"); }); + test("AMD avoids a name a rest element binds", async () => { + const spec = new RecipeSpec(); + const bound: {name?: string} = {}; + spec.recipe = fromVisitor(rebind("sap/ui/core/Element", bound)); + await spec.rewriteRun(javascript( + `sap.ui.define([], function () { const [...Element] = window; target(); });`, + `sap.ui.define(["sap/ui/core/Element"], function (Element_1) { const [...Element] = window; Element_1.target(); });` + )); + expect(bound.name).toBe("Element_1"); + }); + test("AMD refuses where a parameter would pair with the wrong dependency", async () => { const spec = new RecipeSpec(); const bound: {name?: string} = {}; From efdc4e27b221e1567e38290d69a893402077dbca Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Thu, 27 Aug 2026 20:44:24 +0200 Subject: [PATCH 19/41] JavaScript: moduleSystem reports "none" for a plain script 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. --- .../rewrite/src/javascript/bind-module.ts | 28 +++++++++++++++++-- .../test/javascript/bind-module.test.ts | 12 ++++++-- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/rewrite-javascript/rewrite/src/javascript/bind-module.ts b/rewrite-javascript/rewrite/src/javascript/bind-module.ts index 5efa487cd4..fb52312e04 100644 --- a/rewrite-javascript/rewrite/src/javascript/bind-module.ts +++ b/rewrite-javascript/rewrite/src/javascript/bind-module.ts @@ -51,8 +51,13 @@ export interface ModuleBindings { /** The local name bound to `module`, or undefined when nothing binds it. */ bindingOf(module: string): string | undefined; - /** The lane these bindings come from, and the one `bindModule` would use. */ - readonly moduleSystem: "esm" | "amd" | "commonjs"; + /** + * The lane these bindings come from, and the one `bindModule` would use. `"none"` is a + * plain script — no import, export, `require` binding, or enclosing AMD block — which + * `bindModule` still turns into a module on request; a caller that must not do that checks + * for `"none"` itself. + */ + readonly moduleSystem: "esm" | "amd" | "commonjs" | "none"; } export function calleesOf(options?: BindModuleOptions): readonly string[] { @@ -119,12 +124,29 @@ export function moduleBindings( const cu = compilationUnitOf(visitor); const bound = cu === undefined ? [] : moduleObjectBindings(cu); return { - moduleSystem: cu !== undefined && isCommonJs(cu) ? "commonjs" : "esm", + moduleSystem: cu === undefined ? "esm" : + isCommonJs(cu) ? "commonjs" : + hasEsmSyntax(cu) ? "esm" : "none", moduleOf: localName => bound.find(b => b.name === localName)?.module, bindingOf: module => bound.find(b => b.module === module)?.name }; } +/** Whether a top-level statement already marks the file as a module: an import or any export form. */ +function hasEsmSyntax(cu: JS.CompilationUnit): boolean { + return cu.statements.some(stmt => { + const element = stmt.element; + // `export {a, b}`, `export * from` and `export default` are their own statement kinds; + // `export class`/`function`/`const` instead carry `export` as a modifier on the + // declaration itself, the same way TypeScript's own AST models it. + const modifiers = (element as {modifiers?: J.Modifier[]} | undefined)?.modifiers; + return element?.kind === JS.Kind.Import || + element?.kind === JS.Kind.ExportDeclaration || + element?.kind === JS.Kind.ExportAssignment || + (modifiers?.some(m => m.keyword === "export") ?? false); + }); +} + interface ModuleObjectBinding { name: string; module: string; diff --git a/rewrite-javascript/rewrite/test/javascript/bind-module.test.ts b/rewrite-javascript/rewrite/test/javascript/bind-module.test.ts index c2244e2a92..7b26892be9 100644 --- a/rewrite-javascript/rewrite/test/javascript/bind-module.test.ts +++ b/rewrite-javascript/rewrite/test/javascript/bind-module.test.ts @@ -40,11 +40,19 @@ describe("moduleBindings", () => { expect(seen).toEqual({moduleSystem: "esm", module: "sap/m/Button", binding: "Button"}); }); - test("a file with no bindings reports the lane bindModule would take", async () => { + test("a plain script with no import, export, require, or AMD block reports \"none\"", async () => { const spec = new RecipeSpec(); const seen: {moduleSystem?: string} = {}; spec.recipe = fromVisitor(captureBindings(seen)); await spec.rewriteRun(typescript(`const x = 1;`)); + expect(seen.moduleSystem).toBe("none"); + }); + + test("an export alone is enough to read as \"esm\", even with no import", async () => { + const spec = new RecipeSpec(); + const seen: {moduleSystem?: string} = {}; + spec.recipe = fromVisitor(captureBindings(seen)); + await spec.rewriteRun(typescript(`export const x = 1;`)); expect(seen.moduleSystem).toBe("esm"); }); @@ -53,7 +61,7 @@ describe("moduleBindings", () => { const seen: {moduleSystem?: string} = {}; spec.recipe = fromVisitor(captureBindings(seen)); await spec.rewriteRun(javascript(`const other = foo.require("a/Other");`)); - expect(seen.moduleSystem).toBe("esm"); + expect(seen.moduleSystem).toBe("none"); }); test("a .cjs file reads as CommonJS even with no require call in it yet", async () => { From fbcc726eb85987a3bc53b009526c9ea853c02a5d Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Thu, 27 Aug 2026 20:56:48 +0200 Subject: [PATCH 20/41] JavaScript: trim two bindModule comments to what their neighbours do not already say --- .../rewrite/src/javascript/bind-module.ts | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/rewrite-javascript/rewrite/src/javascript/bind-module.ts b/rewrite-javascript/rewrite/src/javascript/bind-module.ts index fb52312e04..54afeda4fe 100644 --- a/rewrite-javascript/rewrite/src/javascript/bind-module.ts +++ b/rewrite-javascript/rewrite/src/javascript/bind-module.ts @@ -484,11 +484,9 @@ export class AddAmdDependency

extends JavaScriptVisitor

{ // Nothing referenced the binding, so the caller asked and then did not use the answer. return visited; } - // withDependency re-checks the parameter/dependency count against the tree as it stands - // now: another edit in the same visit that drops or adds a factory parameter can reopen - // a mismatch bindAmd's own check already cleared. The caller has already emitted a - // reference to `this.binding`, so — as for the missing-block case above — a mismatch - // here is an error, not a silently dropped dependency. + // Another edit in the same visit can drop or add a factory parameter, reopening a count + // mismatch bindAmd's check already cleared — an error here for the same reason a missing + // block is one. const dependency = withDependency(visited, block, this.module, this.binding); if (dependency === undefined) { throw new Error( @@ -500,11 +498,10 @@ export class AddAmdDependency

extends JavaScriptVisitor

{ } /** - * Drops AMD bindings a rewrite left unreferenced. A binding already unused before the rewrite - * stays: it is loaded for its side effects, so removing it would change what the module loads, - * not just what gets called. This can't defer onto `visitor.afterVisit` like the rest of this - * module's edits — it needs the tree as it stood before the rewrite. Blocks are matched by the - * call's id, so one a rewrite rebuilds rather than edits is silently left alone. + * Drops AMD bindings a rewrite left unreferenced. One already unused beforehand stays: it is + * loaded for its side effects, so dropping it changes what the module loads. Needing the tree as + * it stood before the rewrite is why this takes both and does not defer. Blocks are matched by + * the call's id, so one a rewrite rebuilds rather than edits is left alone. */ export async function removeNewlyUnusedAmdBindings( before: JS.CompilationUnit, From 978024c380b33d11cdb130878b68305d0407d1b6 Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Thu, 27 Aug 2026 22:39:10 +0200 Subject: [PATCH 21/41] JavaScript: bindModule deconflicts through the scope utility, and is synchronous MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scopeOf answers what the cursor can already name — the factory parameters and the declarations reaching it — where declaredNames walked the whole body and counted names from nested scopes that cannot shadow a parameter. It also reads binding patterns, nesting and rest elements, which the walker had to be taught one shape at a time. The walk was the only await on the binding path, so the operation is now synchronous. --- .../rewrite/src/javascript/bind-module.ts | 75 +++---------------- .../test/javascript/bind-module.test.ts | 14 ++-- 2 files changed, 17 insertions(+), 72 deletions(-) diff --git a/rewrite-javascript/rewrite/src/javascript/bind-module.ts b/rewrite-javascript/rewrite/src/javascript/bind-module.ts index 54afeda4fe..81582f49f8 100644 --- a/rewrite-javascript/rewrite/src/javascript/bind-module.ts +++ b/rewrite-javascript/rewrite/src/javascript/bind-module.ts @@ -17,6 +17,7 @@ import {isIdentifier, J} from "../java"; import {JS} from "./tree"; import {Cursor} from "../tree"; import {JavaScriptVisitor} from "./visitor"; +import {scopeOf} from "./scope"; import {ExecutionContext} from "../execution"; import {UUID} from "../uuid"; import {maybeAddImport, moduleNameOf, QuoteChar} from "./add-import"; @@ -240,11 +241,11 @@ function moduleObjectBindings(cu: JS.CompilationUnit): ModuleObjectBinding[] { * The name is decided from the cursor and returned at once; the edit that creates the binding * is deferred onto `visitor.afterVisit`, as `maybeAddImport`'s is. */ -export async function bindModule( +export function bindModule( visitor: JavaScriptVisitor, module: string | J.Literal, options?: BindModuleOptions -): Promise { +): string | undefined { const moduleName = moduleNameOf(module); const amd = enclosingAmdBlock(visitor, options); if (amd !== undefined) { @@ -279,12 +280,12 @@ export function lastSegment(module: string): string { return module.substring(module.lastIndexOf("/") + 1); } -async function bindAmd( +function bindAmd( visitor: JavaScriptVisitor, amd: {call: J.MethodInvocation, block: AmdBlock}, module: string, options?: BindModuleOptions -): Promise { +): string | undefined { const modules = dependencyNames(amd.block); const bindings = parameterNames(amd.block); @@ -307,7 +308,10 @@ async function bindAmd( return undefined; } - const taken = [...bindings, ...await declaredNames(amd.block, visitor), ...queued.map(v => v.binding)]; + // What the cursor can already name, which is the factory's parameters and the declarations + // reaching it — a name bound in a nested scope cannot shadow the parameter, so it does not count. + const inScope = scopeOf(cursorOf(visitor)!); + const taken = [...bindings, ...inScope.names(), ...queued.map(v => v.binding)]; const binding = deconflict(options?.binding ?? lastSegment(module), taken); visitor.afterVisit.push(new AddAmdDependency(amd.call.id, module, binding, calleesOf(options))); return binding; @@ -330,66 +334,7 @@ function queuedFor(visitor: JavaScriptVisitor, blockId: UUID): AddAmdDepend return visitor.afterVisit.filter((v): v is AddAmdDependency => v instanceof AddAmdDependency && v.blockId === blockId); } -/** - * The names the body declares. A new parameter has to avoid all of them, not just the other - * parameters: a `var Log` in the body would shadow a parameter of the same name, and the - * rewritten code would quietly read the local instead of the module. - */ -async function declaredNames(block: AmdBlock, visitor: JavaScriptVisitor): Promise { - const body = bodyOf(block); - if (body === undefined) { - return []; - } - const names: string[] = []; - const collector = new class extends JavaScriptVisitor { - override async visitVariableDeclarations(v: J.VariableDeclarations, c: ExecutionContext) { - for (const variable of v.variables) { - names.push(...bindingNames(variable.element.name)); - } - return super.visitVariableDeclarations(v, c); - } - - override async visitMethodDeclaration(m: J.MethodDeclaration, c: ExecutionContext) { - names.push(m.name.simpleName); - return super.visitMethodDeclaration(m, c); - } - override async visitClassDeclaration(k: J.ClassDeclaration, c: ExecutionContext) { - names.push(k.name.simpleName); - return super.visitClassDeclaration(k, c); - } - }; - await collector.visit(body, new ExecutionContext()); - return names; -} - -/** - * Every identifier a binding pattern introduces, recursing through nested destructuring so - * `const {a: {b}} = m` yields `b`. A plain identifier introduces itself. - */ -function bindingNames(pattern: J | undefined): string[] { - if (isIdentifier(pattern)) { - return [pattern.simpleName]; - } - if (pattern?.kind === JS.Kind.Spread) { - return bindingNames((pattern as JS.Spread).expression); - } - const elements = pattern?.kind === JS.Kind.ObjectBindingPattern - ? (pattern as JS.ObjectBindingPattern).bindings.elements - : pattern?.kind === JS.Kind.ArrayBindingPattern - ? (pattern as JS.ArrayBindingPattern).elements.elements - : undefined; - if (elements === undefined) { - return []; - } - const names: string[] = []; - for (const elem of elements) { - if (elem.element?.kind === JS.Kind.BindingElement) { - names.push(...bindingNames((elem.element as JS.BindingElement).name)); - } - } - return names; -} /** The factory's body: a `J.Block` for `function(){}` and most arrows, or the bare expression for `() => expr`. */ export function bodyOf(block: AmdBlock): J | undefined { @@ -399,7 +344,7 @@ export function bodyOf(block: AmdBlock): J | undefined { } /** - * Whether the factory body references `name`. The pool is wider than `declaredNames`'s: it + * Whether the factory body references `name`. This counts any identifier of that name, so it * matches any identifier of that name, including a member in a field access, since a stray * dependency is fine where a shadowed one is not. `missingBodyAnswer` covers a body-less * factory: `false` for an addition (nothing to conflict with), `true` for a removal (nothing diff --git a/rewrite-javascript/rewrite/test/javascript/bind-module.test.ts b/rewrite-javascript/rewrite/test/javascript/bind-module.test.ts index 7b26892be9..5b6209e0d7 100644 --- a/rewrite-javascript/rewrite/test/javascript/bind-module.test.ts +++ b/rewrite-javascript/rewrite/test/javascript/bind-module.test.ts @@ -177,7 +177,7 @@ function rebind(module: string, bound: {name?: string}) { if (m.name.simpleName !== "target") { return super.visitMethodInvocation(m, p); } - bound.name = await bindModule(this, module); + bound.name = bindModule(this, module); return bound.name === undefined ? m : withReference(m, bound.name); } }; @@ -190,7 +190,7 @@ function askAndAbandon(module: string, bound: {name?: string}) { if (m.name.simpleName !== "target") { return super.visitMethodInvocation(m, p); } - bound.name = await bindModule(this, module); + bound.name = bindModule(this, module); return m; } }; @@ -314,7 +314,7 @@ describe("bindModule", () => { return super.visitMethodInvocation(m, p); } const bound = bound1.name === undefined ? bound1 : bound2; - bound.name = await bindModule(this, "sap/ui/core/Element"); + bound.name = bindModule(this, "sap/ui/core/Element"); return bound.name === undefined ? m : withReference(m, bound.name); } }); @@ -333,11 +333,11 @@ describe("bindModule", () => { spec.recipe = fromVisitor(new class extends JavaScriptVisitor { override async visitMethodInvocation(m: J.MethodInvocation, p: any): Promise { if (m.name.simpleName === "targetA") { - boundA.name = await bindModule(this, "a/Element"); + boundA.name = bindModule(this, "a/Element"); return boundA.name === undefined ? m : withReference(m, boundA.name); } if (m.name.simpleName === "targetB") { - boundB.name = await bindModule(this, "b/Element"); + boundB.name = bindModule(this, "b/Element"); return boundB.name === undefined ? m : withReference(m, boundB.name); } return super.visitMethodInvocation(m, p); @@ -357,7 +357,7 @@ describe("bindModule", () => { spec.recipe = fromVisitor(new class extends JavaScriptVisitor { override async visitMethodInvocation(m: J.MethodInvocation, p: any): Promise { if (m.name.simpleName === "target") { - bound.name = await bindModule(this, "sap/ui/core/Element"); + bound.name = bindModule(this, "sap/ui/core/Element"); return m; } const visited = await super.visitMethodInvocation(m, p) as J.MethodInvocation; @@ -376,7 +376,7 @@ describe("bindModule", () => { spec.recipe = fromVisitor(new class extends JavaScriptVisitor { override async visitMethodInvocation(m: J.MethodInvocation, p: any): Promise { if (m.name.simpleName === "target") { - const name = await bindModule(this, "sap/ui/core/Element"); + const name = bindModule(this, "sap/ui/core/Element"); return name === undefined ? m : withReference(m, name); } const visited = await super.visitMethodInvocation(m, p) as J.MethodInvocation; From a7dbd1aa8ee56b99ffc39faec842018f5298e1d8 Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Thu, 27 Aug 2026 23:13:20 +0200 Subject: [PATCH 22/41] JavaScript: unify maybeAddImport and bindModule into maybeBind Collapses the two module-binding APIs into one: maybeBind serves both the AMD and ESM/CommonJS lanes, and maybeAddImport becomes a deprecated one-line delegation to it. bindModule's own options (binding, esmForm) collapse into AddImportOptions' existing preferredName/member fields. Reorganizes src/javascript around the new entry point: binding.ts holds maybeBind, moduleBindings, isAmdBlock, maybeRemoveImport and the deprecated maybeAddImport shim; amd.ts absorbs everything AMD-specific from the now-deleted bind-module.ts and remove-amd-dependency.ts; add-import.ts's maybeAddImport engine is renamed bindImport and stays the ESM/CommonJS lane maybeBind delegates to. change-import.ts's four maybeAddImport call sites move to maybeBind, gaining AMD support for free. --- .../rewrite/src/javascript/add-import.ts | 48 +- .../rewrite/src/javascript/amd.ts | 312 ++++++++++- .../rewrite/src/javascript/bind-module.ts | 515 ------------------ .../rewrite/src/javascript/binding.ts | 277 ++++++++++ .../rewrite/src/javascript/index.ts | 15 +- .../src/javascript/recipes/change-import.ts | 12 +- .../src/javascript/remove-amd-dependency.ts | 47 -- .../rewrite/src/javascript/remove-import.ts | 42 -- .../src/javascript/templating/template.ts | 2 +- .../test/javascript/add-import.test.ts | 8 +- .../{bind-module.test.ts => binding.test.ts} | 46 +- 11 files changed, 650 insertions(+), 674 deletions(-) delete mode 100644 rewrite-javascript/rewrite/src/javascript/bind-module.ts create mode 100644 rewrite-javascript/rewrite/src/javascript/binding.ts delete mode 100644 rewrite-javascript/rewrite/src/javascript/remove-amd-dependency.ts rename rewrite-javascript/rewrite/test/javascript/{bind-module.test.ts => binding.test.ts} (92%) diff --git a/rewrite-javascript/rewrite/src/javascript/add-import.ts b/rewrite-javascript/rewrite/src/javascript/add-import.ts index 8b70d74e10..b25561934a 100644 --- a/rewrite-javascript/rewrite/src/javascript/add-import.ts +++ b/rewrite-javascript/rewrite/src/javascript/add-import.ts @@ -66,47 +66,11 @@ export interface AddImportOptions { } /** - * Register an AddImport visitor to add an import statement to a JavaScript/TypeScript file - * @param visitor The visitor to add the import addition to - * @param options Configuration options for the import to add - * @returns The local name the module is bound to: an existing binding's where one answers this - * request, otherwise the name the new import will use, suffixed if the file already binds it. `onlyIfReferenced` defaults to true, so - * the import may never appear, and the name is then what it would have gone by. A side-effect - * import binds no name and returns `undefined`. - * - * @example - * // Add a named import - * maybeAddImport(visitor, { module: 'fs', member: 'readFile' }); - * - * @example - * // Add a default import using the 'default' member specifier - * maybeAddImport(visitor, { module: 'react', member: 'default', alias: 'React' }); - * - * @example - * // Add a default import (legacy way, without specifying member) - * maybeAddImport(visitor, { module: 'react', alias: 'React' }); - * - * @example - * // Add a namespace import - * maybeAddImport(visitor, { module: 'crypto', member: '*', alias: 'crypto' }); - * - * @example - * // Add a side-effect import - * maybeAddImport(visitor, { module: 'core-js/stable', sideEffectOnly: true }); + * Ensures an import for `options.module` exists, queuing an `AddImport` edit where none already + * serves the request. `maybeBind`'s ESM/CommonJS lane is this function; its own JSDoc carries the + * return-value contract. */ -export function maybeAddImport( - visitor: JavaScriptVisitor, - options: AddImportOptions & { sideEffectOnly: true } -): undefined; -export function maybeAddImport( - visitor: JavaScriptVisitor, - options: AddImportOptions & { sideEffectOnly?: false } -): string; -export function maybeAddImport( - visitor: JavaScriptVisitor, - options: AddImportOptions -): string | undefined; -export function maybeAddImport( +export function bindImport( visitor: JavaScriptVisitor, options: AddImportOptions ): string | undefined { @@ -233,8 +197,8 @@ interface ModuleScopeBinding { } function cursorOf(visitor: JavaScriptVisitor): Cursor | undefined { - // `cursor` is protected on `TreeVisitor`, and the `maybeAddImport`/`maybeRemoveImport` - // API is free functions, so reaching it takes a cast. + // `cursor` is protected on `TreeVisitor`, and `bindImport`/`maybeRemoveImport` are free + // functions, so reaching it takes a cast. return (visitor as unknown as { cursor?: Cursor }).cursor; } diff --git a/rewrite-javascript/rewrite/src/javascript/amd.ts b/rewrite-javascript/rewrite/src/javascript/amd.ts index 41df9794f6..14eafc39e4 100644 --- a/rewrite-javascript/rewrite/src/javascript/amd.ts +++ b/rewrite-javascript/rewrite/src/javascript/amd.ts @@ -14,7 +14,7 @@ * limitations under the License. */ import {emptyMarkers, findMarker} from "../markers"; -import {randomId} from "../uuid"; +import {randomId, UUID} from "../uuid"; import { emptyContainer, emptySpace, @@ -29,6 +29,9 @@ import { TrailingComma } from "../java"; import {JS} from "./tree"; +import {Cursor} from "../tree"; +import {JavaScriptVisitor} from "./visitor"; +import {ExecutionContext} from "../execution"; /** RequireJS and Dojo write `define`; UI5 namespaces it as `sap.ui.define`. */ export const DEFAULT_AMD_CALLEES: readonly string[] = ["define", "require"]; @@ -508,3 +511,310 @@ function restoreFactory(original: Expression, factory: J.MethodDeclaration | J.L const arrowFunction: JS.ArrowFunction = {...(original as JS.ArrowFunction), lambda: factory as J.Lambda}; return arrowFunction; } + +/** The subset of `MaybeBindOptions` that selects which callees introduce an AMD block. */ +export interface AmdCalleeOptions { + /** Callees that introduce an AMD block. */ + amdCallee?: string | readonly string[]; +} + +export function calleesOf(options?: AmdCalleeOptions): readonly string[] { + const callee = options?.amdCallee; + return callee === undefined ? DEFAULT_AMD_CALLEES : typeof callee === "string" ? [callee] : callee; +} + +/** `cursor` is protected on `TreeVisitor` and this API is free functions, so reaching it takes a cast. */ +function cursorOf(visitor: JavaScriptVisitor): Cursor | undefined { + return (visitor as unknown as {cursor?: Cursor}).cursor; +} + +/** The nearest AMD block the cursor sits inside, which is the one a binding belongs to. */ +export function enclosingAmdBlock( + visitor: JavaScriptVisitor, + options?: AmdCalleeOptions +): {call: J.MethodInvocation, block: AmdBlock} | undefined { + const callees = calleesOf(options); + let cursor = cursorOf(visitor); + while (cursor !== undefined) { + const value = cursor.value as J | undefined; + if (value?.kind === J.Kind.MethodInvocation) { + const block = amdBlockOf(value as J.MethodInvocation, callees); + if (block !== undefined) { + return {call: value as J.MethodInvocation, block}; + } + } + cursor = cursor.parent; + } + return undefined; +} + +/** The conventional local name for a module: its last path segment. */ +export function lastSegment(module: string): string { + return module.substring(module.lastIndexOf("/") + 1); +} + +function deconflict(preferred: string, taken: readonly (string | undefined)[]): string { + if (!taken.includes(preferred)) { + return preferred; + } + for (let suffix = 1; ; suffix++) { + const candidate = `${preferred}_${suffix}`; + if (!taken.includes(candidate)) { + return candidate; + } + } +} + +/** Queued reservations already targeting this block: the shared source for both dedup and deconfliction. */ +function queuedFor(visitor: JavaScriptVisitor, blockId: UUID): AddAmdDependency[] { + return visitor.afterVisit.filter((v): v is AddAmdDependency => v instanceof AddAmdDependency && v.blockId === blockId); +} + +/** + * A local binding for `module` on the AMD lane, creating one where the block does not already + * have it, or `undefined` where no safe binding exists. + * + * The name is decided from the cursor and returned at once; the edit that creates the binding + * is deferred onto `visitor.afterVisit`, as `bindImport`'s is. + */ +export function bindAmd( + visitor: JavaScriptVisitor, + amd: {call: J.MethodInvocation, block: AmdBlock}, + module: string, + preferredName: string | undefined, + namesInScope: ReadonlySet, + callees: readonly string[] +): string | undefined { + const modules = dependencyNames(amd.block); + const bindings = parameterNames(amd.block); + + const declared = modules.indexOf(module); + if (declared >= 0) { + return bindings[declared]; + } + + // Two calls naming the same module at the same block are the same request (ADR 0013), + // answered from whichever reservation is already queued rather than doubling the dependency. + const queued = queuedFor(visitor, amd.call.id); + const reserved = queued.find(v => v.module === module)?.binding; + if (reserved !== undefined) { + return reserved; + } + + // A parameter can only be appended at the end, so a block whose dependency and parameter + // counts already disagree would bind the new module against the wrong parameter either way. + if (bindings.length !== elementsOf(amd.block).length) { + return undefined; + } + + const taken = [...bindings, ...namesInScope, ...queued.map(v => v.binding)]; + const binding = deconflict(preferredName ?? lastSegment(module), taken); + visitor.afterVisit.push(new AddAmdDependency(amd.call.id, module, binding, callees)); + return binding; +} + +/** The factory's body: a `J.Block` for `function(){}` and most arrows, or the bare expression for `() => expr`. */ +export function bodyOf(block: AmdBlock): J | undefined { + return block.factory.kind === J.Kind.MethodDeclaration ? + (block.factory as J.MethodDeclaration).body : + (block.factory as J.Lambda).body; +} + +/** + * Whether the factory body references `name`. This counts any identifier of that name, so it + * matches any identifier of that name, including a member in a field access, since a stray + * dependency is fine where a shadowed one is not. `missingBodyAnswer` covers a body-less + * factory: `false` for an addition (nothing to conflict with), `true` for a removal (nothing + * to prove the binding unused). + */ +export async function references(block: AmdBlock, name: string, missingBodyAnswer: boolean): Promise { + const body = bodyOf(block); + if (body === undefined) { + return missingBodyAnswer; + } + let found = false; + const finder = new class extends JavaScriptVisitor { + override async visitIdentifier(id: J.Identifier, c: ExecutionContext) { + if (id.simpleName === name) { + found = true; + } + return super.visitIdentifier(id, c); + } + }; + await finder.visit(body, new ExecutionContext()); + return found; +} + +/** + * Every name the factory body references, one walk for the whole block rather than one per + * name. `missingBodyAnswer` means what it means on `references`: the answer `.has` gives, for + * every name, when the factory has no body to walk. + */ +export async function namesUsed(block: AmdBlock, missingBodyAnswer: boolean): Promise<{has(name: string): boolean}> { + const body = bodyOf(block); + if (body === undefined) { + return {has: () => missingBodyAnswer}; + } + const names = new Set(); + const collector = new class extends JavaScriptVisitor { + override async visitIdentifier(id: J.Identifier, c: ExecutionContext) { + names.add(id.simpleName); + return super.visitIdentifier(id, c); + } + }; + await collector.visit(body, new ExecutionContext()); + return names; +} + +/** + * Applies the dependency a `maybeBind` call settled on. The block is found by id: its caller + * has already emitted a reference to the binding, so a block that cannot be found is an error + * rather than a skipped edit. + */ +export class AddAmdDependency

extends JavaScriptVisitor

{ + constructor( + readonly blockId: UUID, + readonly module: string, + readonly binding: string, + readonly callees: readonly string[] + ) { + super(); + } + + private applied = false; + + override async visitJsCompilationUnit(cu: JS.CompilationUnit, p: P): Promise { + const visited = await super.visitJsCompilationUnit(cu, p) as JS.CompilationUnit; + if (!this.applied) { + throw new Error( + `No AMD block ${this.blockId} to declare '${this.module}' on, but '${this.binding}' was reported bound`); + } + return visited; + } + + override async visitMethodInvocation(m: J.MethodInvocation, p: P): Promise { + const visited = await super.visitMethodInvocation(m, p) as J.MethodInvocation; + if (visited.id !== this.blockId) { + return visited; + } + const block = amdBlockOf(visited, this.callees); + if (block === undefined) { + return visited; + } + this.applied = true; + if (!(await references(block, this.binding, false))) { + // Nothing referenced the binding, so the caller asked and then did not use the answer. + return visited; + } + // Another edit in the same visit can drop or add a factory parameter, reopening a count + // mismatch bindAmd's own check already cleared — an error here for the same reason a missing + // block is one. + const dependency = withDependency(visited, block, this.module, this.binding); + if (dependency === undefined) { + throw new Error( + `AMD block ${this.blockId} no longer has matching dependency and parameter counts, ` + + `so '${this.module}' could not be declared for '${this.binding}', which was reported bound`); + } + return dependency; + } +} + +/** + * The AMD counterpart to `RemoveImport`: drops a dependency the block's body no longer names, + * along with the parameter bound to it. A dependency the factory takes no parameter for is + * loaded for its side effects and stays, matching `bindAmd`'s own read of such a block. + */ +export class RemoveAmdDependency

extends JavaScriptVisitor

{ + constructor(readonly module: string, readonly callees: readonly string[] = DEFAULT_AMD_CALLEES) { + super(); + } + + override async visitMethodInvocation(m: J.MethodInvocation, p: P): Promise { + const visited = await super.visitMethodInvocation(m, p) as J.MethodInvocation; + const block = amdBlockOf(visited, this.callees); + if (block === undefined) { + return visited; + } + const index = dependencyNames(block).indexOf(this.module); + if (index < 0) { + return visited; + } + const binding = parameterNames(block)[index]; + if (binding === undefined || await references(block, binding, true)) { + return visited; + } + return withoutDependencyAt(visited, block, index); + } +} + +/** + * Drops AMD bindings a rewrite left unreferenced. One already unused beforehand stays: it is + * loaded for its side effects, so dropping it changes what the module loads. Needing the tree as + * it stood before the rewrite is why this takes both and does not defer. Blocks are matched by + * the call's id, so one a rewrite rebuilds rather than edits is left alone. + */ +export async function removeNewlyUnusedAmdBindings( + before: JS.CompilationUnit, + after: JS.CompilationUnit, + ctx: ExecutionContext, + options?: AmdCalleeOptions +): Promise { + const callees = calleesOf(options); + const usedBeforeByBlock = new Map>(); + const collector = new class extends JavaScriptVisitor { + override async visitMethodInvocation(m: J.MethodInvocation, c: ExecutionContext) { + const block = amdBlockOf(m, callees); + if (block !== undefined) { + usedBeforeByBlock.set(m.id, await usedBindings(block)); + } + return super.visitMethodInvocation(m, c); + } + }; + await collector.visit(before, ctx); + if (usedBeforeByBlock.size === 0) { + return after; + } + + const sweeper = new class extends JavaScriptVisitor { + override async visitMethodInvocation(m: J.MethodInvocation, c: ExecutionContext): Promise { + let call = await super.visitMethodInvocation(m, c) as J.MethodInvocation; + const usedBefore = usedBeforeByBlock.get(call.id); + let block = usedBefore === undefined ? undefined : amdBlockOf(call, callees); + if (usedBefore === undefined || block === undefined) { + return call; + } + // withoutDependencyAt only edits the dependency array and parameter list, never the + // body, so which names it references can't change between removals. + const usedNow = await namesUsed(block, true); + for (; ;) { + const bindings = parameterNames(block); + const goneIndex = bindings.findIndex(binding => + binding !== undefined && usedBefore.has(binding) && !usedNow.has(binding)); + if (goneIndex < 0) { + return call; + } + call = withoutDependencyAt(call, block, goneIndex); + // A removal shifts the indices of the dependencies after it, so the block is + // re-derived from `call` rather than reused with stale offsets. + const next = amdBlockOf(call, callees); + if (next === undefined) { + return call; + } + block = next; + } + } + }; + return await sweeper.visit(after, ctx) as JS.CompilationUnit; +} + +/** The block's parameter names that its body actually references, from a single walk of it. */ +async function usedBindings(block: AmdBlock): Promise> { + const usedNames = await namesUsed(block, false); + const used = new Set(); + for (const binding of parameterNames(block)) { + if (binding !== undefined && usedNames.has(binding)) { + used.add(binding); + } + } + return used; +} diff --git a/rewrite-javascript/rewrite/src/javascript/bind-module.ts b/rewrite-javascript/rewrite/src/javascript/bind-module.ts deleted file mode 100644 index 81582f49f8..0000000000 --- a/rewrite-javascript/rewrite/src/javascript/bind-module.ts +++ /dev/null @@ -1,515 +0,0 @@ -/* - * Copyright 2025 the original author or authors. - *

- * Licensed under the Moderne Source Available License (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - *

- * https://docs.moderne.io/licensing/moderne-source-available-license - *

- * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import {isIdentifier, J} from "../java"; -import {JS} from "./tree"; -import {Cursor} from "../tree"; -import {JavaScriptVisitor} from "./visitor"; -import {scopeOf} from "./scope"; -import {ExecutionContext} from "../execution"; -import {UUID} from "../uuid"; -import {maybeAddImport, moduleNameOf, QuoteChar} from "./add-import"; -import { - AmdBlock, amdBlockOf, DEFAULT_AMD_CALLEES, dependencyNames, elementsOf, parameterNames, present, withDependency, - withoutDependencyAt -} from "./amd"; - -export type EsmBindingForm = "default" | "namespace"; - -export interface BindModuleOptions { - /** - * Preferred local name, defaulting to the module's last path segment. A preference: an - * existing binding or a name already in scope overrides it. - */ - binding?: string; - - /** The form to create on the ESM lane. Ignored on the AMD lane. */ - esmForm?: EsmBindingForm; - - /** Quote character for a created ESM specifier, defaulting to the file's own. */ - quoteStyle?: QuoteChar; - - /** Callees that introduce an AMD block. */ - amdCallee?: string | readonly string[]; -} - -export interface ModuleBindings { - /** The module `localName` refers to, or undefined when it is not a module binding. */ - moduleOf(localName: string): string | undefined; - - /** The local name bound to `module`, or undefined when nothing binds it. */ - bindingOf(module: string): string | undefined; - - /** - * The lane these bindings come from, and the one `bindModule` would use. `"none"` is a - * plain script — no import, export, `require` binding, or enclosing AMD block — which - * `bindModule` still turns into a module on request; a caller that must not do that checks - * for `"none"` itself. - */ - readonly moduleSystem: "esm" | "amd" | "commonjs" | "none"; -} - -export function calleesOf(options?: BindModuleOptions): readonly string[] { - const callee = options?.amdCallee; - return callee === undefined ? DEFAULT_AMD_CALLEES : typeof callee === "string" ? [callee] : callee; -} - -export function isAmdBlock(node: J, options?: Pick): boolean { - return node.kind === J.Kind.MethodInvocation && - amdBlockOf(node as J.MethodInvocation, calleesOf(options)) !== undefined; -} - -/** `cursor` is protected on `TreeVisitor` and this API is free functions, so reaching it takes a cast. */ -export function cursorOf(visitor: JavaScriptVisitor): Cursor | undefined { - return (visitor as unknown as {cursor?: Cursor}).cursor; -} - -/** The nearest AMD block the cursor sits inside, which is the one a binding belongs to. */ -export function enclosingAmdBlock( - visitor: JavaScriptVisitor, - options?: BindModuleOptions -): {call: J.MethodInvocation, block: AmdBlock} | undefined { - const callees = calleesOf(options); - let cursor = cursorOf(visitor); - while (cursor !== undefined) { - const value = cursor.value as J | undefined; - if (value?.kind === J.Kind.MethodInvocation) { - const block = amdBlockOf(value as J.MethodInvocation, callees); - if (block !== undefined) { - return {call: value as J.MethodInvocation, block}; - } - } - cursor = cursor.parent; - } - return undefined; -} - -export function compilationUnitOf(visitor: JavaScriptVisitor): JS.CompilationUnit | undefined { - return cursorOf(visitor)?.firstEnclosing( - (v): v is JS.CompilationUnit => v?.kind === JS.Kind.CompilationUnit); -} - -export function moduleBindings( - visitor: JavaScriptVisitor, - options?: Pick -): ModuleBindings { - const amd = enclosingAmdBlock(visitor, options); - if (amd !== undefined) { - const modules = dependencyNames(amd.block); - const bindings = parameterNames(amd.block); - return { - moduleSystem: "amd", - moduleOf: localName => { - const index = bindings.indexOf(localName); - return index < 0 ? undefined : modules[index]; - }, - bindingOf: module => { - const index = modules.indexOf(module); - return index < 0 ? undefined : bindings[index]; - } - }; - } - - const cu = compilationUnitOf(visitor); - const bound = cu === undefined ? [] : moduleObjectBindings(cu); - return { - moduleSystem: cu === undefined ? "esm" : - isCommonJs(cu) ? "commonjs" : - hasEsmSyntax(cu) ? "esm" : "none", - moduleOf: localName => bound.find(b => b.name === localName)?.module, - bindingOf: module => bound.find(b => b.module === module)?.name - }; -} - -/** Whether a top-level statement already marks the file as a module: an import or any export form. */ -function hasEsmSyntax(cu: JS.CompilationUnit): boolean { - return cu.statements.some(stmt => { - const element = stmt.element; - // `export {a, b}`, `export * from` and `export default` are their own statement kinds; - // `export class`/`function`/`const` instead carry `export` as a modifier on the - // declaration itself, the same way TypeScript's own AST models it. - const modifiers = (element as {modifiers?: J.Modifier[]} | undefined)?.modifiers; - return element?.kind === JS.Kind.Import || - element?.kind === JS.Kind.ExportDeclaration || - element?.kind === JS.Kind.ExportAssignment || - (modifiers?.some(m => m.keyword === "export") ?? false); - }); -} - -interface ModuleObjectBinding { - name: string; - module: string; -} - -/** Whether the file binds its modules with `require`, which decides whether a create is possible. */ -function isCommonJs(cu: JS.CompilationUnit): boolean { - if (cu.sourcePath.endsWith(".cjs")) { - return true; - } - let requires = false; - for (const stmt of cu.statements) { - if (stmt.element?.kind === JS.Kind.Import) { - return false; - } - if (requiredModule(stmt.element) !== undefined) { - requires = true; - } - } - return requires; -} - -/** The module a top-level `const X = require("m")` names, for the one variable it declares. */ -function requiredModule(statement: J | undefined): string | undefined { - if (statement?.kind !== J.Kind.VariableDeclarations) { - return undefined; - } - const variables = (statement as J.VariableDeclarations).variables; - const initializer = variables.length === 1 ? variables[0].element?.initializer?.element : undefined; - if (initializer?.kind !== J.Kind.MethodInvocation) { - return undefined; - } - const call = initializer as J.MethodInvocation; - // `obj.require('x')` selects a method rather than loading a module, matching add-import.ts's - // own `requiredModuleOf`. - if (call.select || call.name.simpleName !== "require") { - return undefined; - } - const argument = present(call.arguments.elements)[0]?.element; - return argument?.kind === J.Kind.Literal && typeof (argument as J.Literal).value === "string" - ? (argument as J.Literal).value as string - : undefined; -} - -/** Only whole-module bindings: a named member does not name the module object. */ -function moduleObjectBindings(cu: JS.CompilationUnit): ModuleObjectBinding[] { - const bindings: ModuleObjectBinding[] = []; - for (const stmt of cu.statements) { - const statement = stmt.element; - if (statement?.kind !== JS.Kind.Import) { - continue; - } - const jsImport = statement as JS.Import; - const specifier = jsImport.moduleSpecifier?.element; - if (specifier?.kind !== J.Kind.Literal) { - continue; - } - const module = (specifier as J.Literal).value; - if (typeof module !== "string") { - continue; - } - const clause = jsImport.importClause; - if (clause?.name?.element?.kind === J.Kind.Identifier) { - bindings.push({name: (clause.name.element as J.Identifier).simpleName, module}); - } - // `namedBindings` is a `JS.Alias` only for `import * as X from "m"`; a renamed - // named import (`{a as b}`) nests its alias inside `NamedImports` instead. - const named = clause?.namedBindings; - if (named?.kind === JS.Kind.Alias) { - const alias = (named as JS.Alias).alias; - if (alias?.kind === J.Kind.Identifier) { - bindings.push({name: (alias as J.Identifier).simpleName, module}); - } - } - } - for (const stmt of cu.statements) { - const module = requiredModule(stmt.element); - const name = module === undefined ? undefined : - (stmt.element as J.VariableDeclarations).variables[0]?.element?.name; - if (module !== undefined && name?.kind === J.Kind.Identifier) { - bindings.push({name: (name as J.Identifier).simpleName, module}); - } - } - return bindings; -} - -/** - * A local binding for `module`, creating one where the file does not already have it, or - * `undefined` where no safe binding exists. - * - * The name is decided from the cursor and returned at once; the edit that creates the binding - * is deferred onto `visitor.afterVisit`, as `maybeAddImport`'s is. - */ -export function bindModule( - visitor: JavaScriptVisitor, - module: string | J.Literal, - options?: BindModuleOptions -): string | undefined { - const moduleName = moduleNameOf(module); - const amd = enclosingAmdBlock(visitor, options); - if (amd !== undefined) { - return bindAmd(visitor, amd, moduleName, options); - } - if (compilationUnitOf(visitor) === undefined) { - // Without a compilation unit there is no lane and no lookup, and the caller emits a - // reference against whatever comes back. - return undefined; - } - const bindings = moduleBindings(visitor, options); - const bound = bindings.bindingOf(moduleName); - if (bound !== undefined) { - return bound; - } - if (bindings.moduleSystem === "commonjs") { - // A `require` answers for a module it already binds, but creating one is unimplemented, - // and an `import` in a file that has none would be the wrong form. - return undefined; - } - return maybeAddImport(visitor, { - module, - member: options?.esmForm === "namespace" ? "*" : "default", - preferredName: options?.binding ?? lastSegment(moduleName), - quoteStyle: options?.quoteStyle, - onlyIfReferenced: false - }); -} - -/** The conventional local name for a module: its last path segment. */ -export function lastSegment(module: string): string { - return module.substring(module.lastIndexOf("/") + 1); -} - -function bindAmd( - visitor: JavaScriptVisitor, - amd: {call: J.MethodInvocation, block: AmdBlock}, - module: string, - options?: BindModuleOptions -): string | undefined { - const modules = dependencyNames(amd.block); - const bindings = parameterNames(amd.block); - - const declared = modules.indexOf(module); - if (declared >= 0) { - return bindings[declared]; - } - - // Two calls naming the same module at the same block are the same request (ADR 0013), - // answered from whichever reservation is already queued rather than doubling the dependency. - const queued = queuedFor(visitor, amd.call.id); - const reserved = queued.find(v => v.module === module)?.binding; - if (reserved !== undefined) { - return reserved; - } - - // A parameter can only be appended at the end, so a block whose dependency and parameter - // counts already disagree would bind the new module against the wrong parameter either way. - if (bindings.length !== elementsOf(amd.block).length) { - return undefined; - } - - // What the cursor can already name, which is the factory's parameters and the declarations - // reaching it — a name bound in a nested scope cannot shadow the parameter, so it does not count. - const inScope = scopeOf(cursorOf(visitor)!); - const taken = [...bindings, ...inScope.names(), ...queued.map(v => v.binding)]; - const binding = deconflict(options?.binding ?? lastSegment(module), taken); - visitor.afterVisit.push(new AddAmdDependency(amd.call.id, module, binding, calleesOf(options))); - return binding; -} - -function deconflict(preferred: string, taken: readonly (string | undefined)[]): string { - if (!taken.includes(preferred)) { - return preferred; - } - for (let suffix = 1; ; suffix++) { - const candidate = `${preferred}_${suffix}`; - if (!taken.includes(candidate)) { - return candidate; - } - } -} - -/** Queued reservations already targeting this block: the shared source for both dedup and deconfliction. */ -function queuedFor(visitor: JavaScriptVisitor, blockId: UUID): AddAmdDependency[] { - return visitor.afterVisit.filter((v): v is AddAmdDependency => v instanceof AddAmdDependency && v.blockId === blockId); -} - - - -/** The factory's body: a `J.Block` for `function(){}` and most arrows, or the bare expression for `() => expr`. */ -export function bodyOf(block: AmdBlock): J | undefined { - return block.factory.kind === J.Kind.MethodDeclaration ? - (block.factory as J.MethodDeclaration).body : - (block.factory as J.Lambda).body; -} - -/** - * Whether the factory body references `name`. This counts any identifier of that name, so it - * matches any identifier of that name, including a member in a field access, since a stray - * dependency is fine where a shadowed one is not. `missingBodyAnswer` covers a body-less - * factory: `false` for an addition (nothing to conflict with), `true` for a removal (nothing - * to prove the binding unused). - */ -export async function references(block: AmdBlock, name: string, missingBodyAnswer: boolean): Promise { - const body = bodyOf(block); - if (body === undefined) { - return missingBodyAnswer; - } - let found = false; - const finder = new class extends JavaScriptVisitor { - override async visitIdentifier(id: J.Identifier, c: ExecutionContext) { - if (id.simpleName === name) { - found = true; - } - return super.visitIdentifier(id, c); - } - }; - await finder.visit(body, new ExecutionContext()); - return found; -} - -/** - * Every name the factory body references, one walk for the whole block rather than one per - * name. `missingBodyAnswer` means what it means on `references`: the answer `.has` gives, for - * every name, when the factory has no body to walk. - */ -export async function namesUsed(block: AmdBlock, missingBodyAnswer: boolean): Promise<{has(name: string): boolean}> { - const body = bodyOf(block); - if (body === undefined) { - return {has: () => missingBodyAnswer}; - } - const names = new Set(); - const collector = new class extends JavaScriptVisitor { - override async visitIdentifier(id: J.Identifier, c: ExecutionContext) { - names.add(id.simpleName); - return super.visitIdentifier(id, c); - } - }; - await collector.visit(body, new ExecutionContext()); - return names; -} - -/** - * Applies the dependency a `bindModule` call settled on. The block is found by id: its caller - * has already emitted a reference to the binding, so a block that cannot be found is an error - * rather than a skipped edit. - */ -export class AddAmdDependency

extends JavaScriptVisitor

{ - constructor( - readonly blockId: UUID, - readonly module: string, - readonly binding: string, - readonly callees: readonly string[] - ) { - super(); - } - - private applied = false; - - override async visitJsCompilationUnit(cu: JS.CompilationUnit, p: P): Promise { - const visited = await super.visitJsCompilationUnit(cu, p) as JS.CompilationUnit; - if (!this.applied) { - throw new Error( - `No AMD block ${this.blockId} to declare '${this.module}' on, but '${this.binding}' was reported bound`); - } - return visited; - } - - override async visitMethodInvocation(m: J.MethodInvocation, p: P): Promise { - const visited = await super.visitMethodInvocation(m, p) as J.MethodInvocation; - if (visited.id !== this.blockId) { - return visited; - } - const block = amdBlockOf(visited, this.callees); - if (block === undefined) { - return visited; - } - this.applied = true; - if (!(await references(block, this.binding, false))) { - // Nothing referenced the binding, so the caller asked and then did not use the answer. - return visited; - } - // Another edit in the same visit can drop or add a factory parameter, reopening a count - // mismatch bindAmd's check already cleared — an error here for the same reason a missing - // block is one. - const dependency = withDependency(visited, block, this.module, this.binding); - if (dependency === undefined) { - throw new Error( - `AMD block ${this.blockId} no longer has matching dependency and parameter counts, ` + - `so '${this.module}' could not be declared for '${this.binding}', which was reported bound`); - } - return dependency; - } -} - -/** - * Drops AMD bindings a rewrite left unreferenced. One already unused beforehand stays: it is - * loaded for its side effects, so dropping it changes what the module loads. Needing the tree as - * it stood before the rewrite is why this takes both and does not defer. Blocks are matched by - * the call's id, so one a rewrite rebuilds rather than edits is left alone. - */ -export async function removeNewlyUnusedAmdBindings( - before: JS.CompilationUnit, - after: JS.CompilationUnit, - ctx: ExecutionContext, - options?: BindModuleOptions -): Promise { - const callees = calleesOf(options); - const usedBeforeByBlock = new Map>(); - const collector = new class extends JavaScriptVisitor { - override async visitMethodInvocation(m: J.MethodInvocation, c: ExecutionContext) { - const block = amdBlockOf(m, callees); - if (block !== undefined) { - usedBeforeByBlock.set(m.id, await usedBindings(block)); - } - return super.visitMethodInvocation(m, c); - } - }; - await collector.visit(before, ctx); - if (usedBeforeByBlock.size === 0) { - return after; - } - - const sweeper = new class extends JavaScriptVisitor { - override async visitMethodInvocation(m: J.MethodInvocation, c: ExecutionContext): Promise { - let call = await super.visitMethodInvocation(m, c) as J.MethodInvocation; - const usedBefore = usedBeforeByBlock.get(call.id); - let block = usedBefore === undefined ? undefined : amdBlockOf(call, callees); - if (usedBefore === undefined || block === undefined) { - return call; - } - // withoutDependencyAt only edits the dependency array and parameter list, never the - // body, so which names it references can't change between removals. - const usedNow = await namesUsed(block, true); - for (; ;) { - const bindings = parameterNames(block); - const goneIndex = bindings.findIndex(binding => - binding !== undefined && usedBefore.has(binding) && !usedNow.has(binding)); - if (goneIndex < 0) { - return call; - } - call = withoutDependencyAt(call, block, goneIndex); - // A removal shifts the indices of the dependencies after it, so the block is - // re-derived from `call` rather than reused with stale offsets. - const next = amdBlockOf(call, callees); - if (next === undefined) { - return call; - } - block = next; - } - } - }; - return await sweeper.visit(after, ctx) as JS.CompilationUnit; -} - -/** The block's parameter names that its body actually references, from a single walk of it. */ -async function usedBindings(block: AmdBlock): Promise> { - const usedNames = await namesUsed(block, false); - const used = new Set(); - for (const binding of parameterNames(block)) { - if (binding !== undefined && usedNames.has(binding)) { - used.add(binding); - } - } - return used; -} diff --git a/rewrite-javascript/rewrite/src/javascript/binding.ts b/rewrite-javascript/rewrite/src/javascript/binding.ts new file mode 100644 index 0000000000..bddc46a5ee --- /dev/null +++ b/rewrite-javascript/rewrite/src/javascript/binding.ts @@ -0,0 +1,277 @@ +/* + * Copyright 2025 the original author or authors. + *

+ * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://docs.moderne.io/licensing/moderne-source-available-license + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import {J} from "../java"; +import {JS} from "./tree"; +import {Cursor} from "../tree"; +import {JavaScriptVisitor} from "./visitor"; +import {scopeOf} from "./scope"; +import {AddImportOptions, bindImport, moduleNameOf} from "./add-import"; +import {RemoveImport} from "./remove-import"; +import { + AmdCalleeOptions, amdBlockOf, bindAmd, calleesOf, dependencyNames, enclosingAmdBlock, lastSegment, + parameterNames, RemoveAmdDependency +} from "./amd"; + +export interface MaybeBindOptions extends AddImportOptions { + /** Callees that introduce an AMD block. UI5 writes `sap.ui.define`, RequireJS and Dojo `define`. */ + amdCallee?: string | readonly string[]; +} + +export interface ModuleBindings { + /** The module `localName` refers to, or undefined when it is not a module binding. */ + moduleOf(localName: string): string | undefined; + + /** The local name bound to `module`, or undefined when nothing binds it. */ + bindingOf(module: string): string | undefined; + + /** + * The lane these bindings come from, and the one `maybeBind` would use. `"none"` is a + * plain script — no import, export, `require` binding, or enclosing AMD block — which + * `maybeBind` still turns into a module on request; a caller that must not do that checks + * for `"none"` itself. + */ + readonly moduleSystem: "esm" | "amd" | "commonjs" | "none"; +} + +/** `cursor` is protected on `TreeVisitor` and this API is free functions, so reaching it takes a cast. */ +function cursorOf(visitor: JavaScriptVisitor): Cursor | undefined { + return (visitor as unknown as {cursor?: Cursor}).cursor; +} + +function compilationUnitOf(visitor: JavaScriptVisitor): JS.CompilationUnit | undefined { + return cursorOf(visitor)?.firstEnclosing( + (v): v is JS.CompilationUnit => v?.kind === JS.Kind.CompilationUnit); +} + +export function moduleBindings( + visitor: JavaScriptVisitor, + options?: AmdCalleeOptions +): ModuleBindings { + const amd = enclosingAmdBlock(visitor, options); + if (amd !== undefined) { + const modules = dependencyNames(amd.block); + const bindings = parameterNames(amd.block); + return { + moduleSystem: "amd", + moduleOf: localName => { + const index = bindings.indexOf(localName); + return index < 0 ? undefined : modules[index]; + }, + bindingOf: module => { + const index = modules.indexOf(module); + return index < 0 ? undefined : bindings[index]; + } + }; + } + + const cu = compilationUnitOf(visitor); + const bound = cu === undefined ? [] : moduleObjectBindings(cu); + return { + moduleSystem: cu === undefined ? "esm" : + isCommonJs(cu) ? "commonjs" : + hasEsmSyntax(cu) ? "esm" : "none", + moduleOf: localName => bound.find(b => b.name === localName)?.module, + bindingOf: module => bound.find(b => b.module === module)?.name + }; +} + +export function isAmdBlock(node: J, options?: AmdCalleeOptions): boolean { + return node.kind === J.Kind.MethodInvocation && + amdBlockOf(node as J.MethodInvocation, calleesOf(options)) !== undefined; +} + +/** Whether a top-level statement already marks the file as a module: an import or any export form. */ +function hasEsmSyntax(cu: JS.CompilationUnit): boolean { + return cu.statements.some(stmt => { + const element = stmt.element; + // `export {a, b}`, `export * from` and `export default` are their own statement kinds; + // `export class`/`function`/`const` instead carry `export` as a modifier on the + // declaration itself, the same way TypeScript's own AST models it. + const modifiers = (element as {modifiers?: J.Modifier[]} | undefined)?.modifiers; + return element?.kind === JS.Kind.Import || + element?.kind === JS.Kind.ExportDeclaration || + element?.kind === JS.Kind.ExportAssignment || + (modifiers?.some(m => m.keyword === "export") ?? false); + }); +} + +interface ModuleObjectBinding { + name: string; + module: string; +} + +/** Whether the file binds its modules with `require`, which decides whether a create is possible. */ +function isCommonJs(cu: JS.CompilationUnit): boolean { + if (cu.sourcePath.endsWith(".cjs")) { + return true; + } + let requires = false; + for (const stmt of cu.statements) { + if (stmt.element?.kind === JS.Kind.Import) { + return false; + } + if (requiredModule(stmt.element) !== undefined) { + requires = true; + } + } + return requires; +} + +/** The module a top-level `const X = require("m")` names, for the one variable it declares. */ +function requiredModule(statement: J | undefined): string | undefined { + if (statement?.kind !== J.Kind.VariableDeclarations) { + return undefined; + } + const variables = (statement as J.VariableDeclarations).variables; + const initializer = variables.length === 1 ? variables[0].element?.initializer?.element : undefined; + if (initializer?.kind !== J.Kind.MethodInvocation) { + return undefined; + } + const call = initializer as J.MethodInvocation; + // `obj.require('x')` selects a method rather than loading a module, matching add-import.ts's + // own `requiredModuleOf`. + if (call.select || call.name.simpleName !== "require") { + return undefined; + } + const argument = call.arguments.elements[0]?.element; + return argument?.kind === J.Kind.Literal && typeof (argument as J.Literal).value === "string" + ? (argument as J.Literal).value as string + : undefined; +} + +/** Only whole-module bindings: a named member does not name the module object. */ +function moduleObjectBindings(cu: JS.CompilationUnit): ModuleObjectBinding[] { + const bindings: ModuleObjectBinding[] = []; + for (const stmt of cu.statements) { + const statement = stmt.element; + if (statement?.kind !== JS.Kind.Import) { + continue; + } + const jsImport = statement as JS.Import; + const specifier = jsImport.moduleSpecifier?.element; + if (specifier?.kind !== J.Kind.Literal) { + continue; + } + const module = (specifier as J.Literal).value; + if (typeof module !== "string") { + continue; + } + const clause = jsImport.importClause; + if (clause?.name?.element?.kind === J.Kind.Identifier) { + bindings.push({name: (clause.name.element as J.Identifier).simpleName, module}); + } + // `namedBindings` is a `JS.Alias` only for `import * as X from "m"`; a renamed + // named import (`{a as b}`) nests its alias inside `NamedImports` instead. + const named = clause?.namedBindings; + if (named?.kind === JS.Kind.Alias) { + const alias = (named as JS.Alias).alias; + if (alias?.kind === J.Kind.Identifier) { + bindings.push({name: (alias as J.Identifier).simpleName, module}); + } + } + } + for (const stmt of cu.statements) { + const module = requiredModule(stmt.element); + const name = module === undefined ? undefined : + (stmt.element as J.VariableDeclarations).variables[0]?.element?.name; + if (module !== undefined && name?.kind === J.Kind.Identifier) { + bindings.push({name: (name as J.Identifier).simpleName, module}); + } + } + return bindings; +} + +/** + * A local binding for `module` or one of its members, creating one where none exists, or + * `undefined` where no safe binding is possible. The lane — AMD or ESM/CommonJS — is decided + * from the cursor; AMD binds only the whole module, so a `member` request there refuses rather + * than guess. `onlyIfReferenced` defaults to true, so the import may never appear. + */ +export function maybeBind( + visitor: JavaScriptVisitor, + options: MaybeBindOptions +): string | undefined { + const module = moduleNameOf(options.module); + + const amd = enclosingAmdBlock(visitor, options); + if (amd !== undefined) { + if (options.member !== undefined || options.sideEffectOnly) { + // Nor can a factory parameter load a module without binding it to a name. + return undefined; + } + const namesInScope = scopeOf(cursorOf(visitor)!).names(); + return bindAmd(visitor, amd, module, options.preferredName, namesInScope, calleesOf(options)); + } + + const bindings = moduleBindings(visitor, options); + const isWholeModule = !options.sideEffectOnly && (options.member === undefined || options.member === "*"); + if (isWholeModule) { + const bound = bindings.bindingOf(module); + if (bound !== undefined) { + return bound; + } + if (bindings.moduleSystem === "commonjs") { + // A `require` answers for a module it already binds, but creating one is + // unimplemented, and an `import` in a file that has none would be the wrong form. + return undefined; + } + } + return bindImport(visitor, { + ...options, + preferredName: options.preferredName ?? (isWholeModule ? lastSegment(module) : undefined) + }); +} + +/** + * Removes `module`'s import(s) where unused, or one `member` of it — `'default'` and `'*'` select + * the default and namespace import regardless of local name. Applies to the ESM lane only; an AMD + * dependency binds a whole module, so `member` is ignored there. + */ +export function maybeRemoveImport(visitor: JavaScriptVisitor, module: string, member?: string) { + for (const v of visitor.afterVisit || []) { + if (v instanceof RemoveImport && v.module === module && v.member === member) { + return; + } + } + visitor.afterVisit.push(new RemoveImport(module, member)); + // Both queue unconditionally so the caller need not know which lane the file uses: each + // visitor removes whatever matching construct it finds, ESM import or AMD dependency, and a + // file with both gets both removed. + visitor.afterVisit.push(new RemoveAmdDependency(module)); +} + +/** + * @deprecated Use {@link maybeBind} instead — this is a rename, not a behaviour change, beyond + * `maybeBind` additionally binding through an AMD factory parameter where this only ever imports. + */ +export function maybeAddImport( + visitor: JavaScriptVisitor, + options: AddImportOptions & { sideEffectOnly: true } +): undefined; +export function maybeAddImport( + visitor: JavaScriptVisitor, + options: AddImportOptions & { sideEffectOnly?: false } +): string; +export function maybeAddImport( + visitor: JavaScriptVisitor, + options: AddImportOptions +): string | undefined; +export function maybeAddImport( + visitor: JavaScriptVisitor, + options: AddImportOptions +): string | undefined { + return maybeBind(visitor, options); +} diff --git a/rewrite-javascript/rewrite/src/javascript/index.ts b/rewrite-javascript/rewrite/src/javascript/index.ts index a6ebf1d39d..241dcdd277 100644 --- a/rewrite-javascript/rewrite/src/javascript/index.ts +++ b/rewrite-javascript/rewrite/src/javascript/index.ts @@ -32,14 +32,17 @@ export * from "./tree-debug"; export * from "./project-parser"; export * from "./scope"; -export * from "./add-import"; -// AMD mechanics `recipes-ui5` builds on directly, beyond the `bindModule` surface below. +export type {QuoteChar, AddImportOptions} from "./add-import"; +export {ImportStyle, moduleNameOf, AddImport} from "./add-import"; +// AMD mechanics `recipes-ui5` builds on directly, beyond the `maybeBind` surface below. export type {AmdBlock} from "./amd"; -export {DEFAULT_AMD_CALLEES, amdBlockOf, dependencyNames, parameterNames, withDependency, withoutDependencyAt} from "./amd"; -export type {EsmBindingForm, BindModuleOptions, ModuleBindings} from "./bind-module"; -export {bindModule, moduleBindings, isAmdBlock, removeNewlyUnusedAmdBindings} from "./bind-module"; +export { + DEFAULT_AMD_CALLEES, amdBlockOf, dependencyNames, parameterNames, withDependency, withoutDependencyAt, + RemoveAmdDependency, removeNewlyUnusedAmdBindings +} from "./amd"; +export type {MaybeBindOptions, ModuleBindings} from "./binding"; +export {maybeBind, moduleBindings, isAmdBlock, maybeRemoveImport, maybeAddImport} from "./binding"; export * from "./remove-import"; -export * from "./remove-amd-dependency"; export * from "./cleanup/index"; export * from "./recipes/index"; export * from "./search/index"; diff --git a/rewrite-javascript/rewrite/src/javascript/recipes/change-import.ts b/rewrite-javascript/rewrite/src/javascript/recipes/change-import.ts index e089a8e59b..4a3e81ad09 100644 --- a/rewrite-javascript/rewrite/src/javascript/recipes/change-import.ts +++ b/rewrite-javascript/rewrite/src/javascript/recipes/change-import.ts @@ -18,7 +18,7 @@ import { Option, Recipe } from "../../recipe"; import { TreeVisitor } from "../../visitor"; import { ExecutionContext } from "../../execution"; import { JavaScriptVisitor, JS } from "../index"; -import { maybeAddImport } from "../add-import"; +import { maybeBind } from "../binding"; import { emptySpace, J, isIdentifier, rightPadded, singleSpace, Type } from "../../java"; import { create as produce, Draft } from "mutative"; import { randomId } from "../../uuid"; @@ -174,26 +174,26 @@ export class ChangeImport extends Recipe { let result = await super.visitJsCompilationUnit(cu, ctx) as JS.CompilationUnit; // If we transformed an import but need to add to existing import from new module, - // or if we only removed a member from a multi-import, use maybeAddImport + // or if we only removed a member from a multi-import, use maybeBind if (this.hasOldImport && !this.transformedImport) { const aliasToUse = newAlias ?? this.oldAlias; if (newMember === 'default') { - maybeAddImport(this, { + maybeBind(this, { module: newModule, member: 'default', alias: aliasToUse, onlyIfReferenced: false }); } else if (newMember === '*') { - maybeAddImport(this, { + maybeBind(this, { module: newModule, member: '*', alias: aliasToUse, onlyIfReferenced: false }); } else { - maybeAddImport(this, { + maybeBind(this, { module: newModule, member: newMember, // A pinned alias is taken verbatim: `oldMember` is the name this @@ -261,7 +261,7 @@ export class ChangeImport extends Recipe { }); } else { // Remove just the specific member from the import - // maybeAddImport will add the new import + // maybeBind will add the new import return this.removeNamedImportMember(imp, oldMember, ctx); } } diff --git a/rewrite-javascript/rewrite/src/javascript/remove-amd-dependency.ts b/rewrite-javascript/rewrite/src/javascript/remove-amd-dependency.ts deleted file mode 100644 index 6105c467e7..0000000000 --- a/rewrite-javascript/rewrite/src/javascript/remove-amd-dependency.ts +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright 2025 the original author or authors. - *

- * Licensed under the Moderne Source Available License (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - *

- * https://docs.moderne.io/licensing/moderne-source-available-license - *

- * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import {J} from "../java"; -import {JavaScriptVisitor} from "./visitor"; -import {amdBlockOf, DEFAULT_AMD_CALLEES, dependencyNames, parameterNames, withoutDependencyAt} from "./amd"; -import {references} from "./bind-module"; - -/** - * The AMD counterpart to `RemoveImport`: drops a dependency the block's body no longer names, - * along with the parameter bound to it. A dependency the factory takes no parameter for is - * loaded for its side effects and stays, matching `bindAmd`'s own read of such a block. - */ -export class RemoveAmdDependency

extends JavaScriptVisitor

{ - constructor(readonly module: string, readonly callees: readonly string[] = DEFAULT_AMD_CALLEES) { - super(); - } - - override async visitMethodInvocation(m: J.MethodInvocation, p: P): Promise { - const visited = await super.visitMethodInvocation(m, p) as J.MethodInvocation; - const block = amdBlockOf(visited, this.callees); - if (block === undefined) { - return visited; - } - const index = dependencyNames(block).indexOf(this.module); - if (index < 0) { - return visited; - } - const binding = parameterNames(block)[index]; - if (binding === undefined || await references(block, binding, true)) { - return visited; - } - return withoutDependencyAt(visited, block, index); - } -} diff --git a/rewrite-javascript/rewrite/src/javascript/remove-import.ts b/rewrite-javascript/rewrite/src/javascript/remove-import.ts index aa5deca23c..52db7812eb 100644 --- a/rewrite-javascript/rewrite/src/javascript/remove-import.ts +++ b/rewrite-javascript/rewrite/src/javascript/remove-import.ts @@ -4,48 +4,6 @@ import {bindingNames} from "./scope"; import {JS, JSX} from "./tree"; import {mapAsync, updateIfChanged} from "../util"; import {ElementRemovalFormatter} from "../java"; -import {RemoveAmdDependency} from "./remove-amd-dependency"; - -/** - * @param visitor The visitor to add the import removal to - * @param module The module name (e.g., 'fs', 'react') to remove imports from - * @param member Optionally, the specific member to remove from the import. - * If not specified, removes all unused imports from the module. - * Special values: - * - 'default': Removes the default import from the module if unused, - * regardless of its local name (e.g., `import React from 'react'`) - * - '*': Removes the namespace import if unused (e.g., `import * as fs from 'fs'`) - * Applies to the ESM lane only; an AMD dependency binds a whole module, so `member` - * is ignored there. - * - * @example - * // Remove a specific named import if unused - * maybeRemoveImport(visitor, 'fs', 'readFile'); - * - * @example - * // Remove the default import from 'react' if unused (regardless of local name) - * maybeRemoveImport(visitor, 'react', 'default'); - * - * @example - * // Remove all unused imports from 'react' module - * maybeRemoveImport(visitor, 'react'); - * - * @example - * // Remove namespace import if unused - * maybeRemoveImport(visitor, 'fs', '*'); - */ -export function maybeRemoveImport(visitor: JavaScriptVisitor, module: string, member?: string) { - for (const v of visitor.afterVisit || []) { - if (v instanceof RemoveImport && v.module === module && v.member === member) { - return; - } - } - visitor.afterVisit.push(new RemoveImport(module, member)); - // Both queue unconditionally so the caller need not know which lane the file uses: each - // visitor removes whatever matching construct it finds, ESM import or AMD dependency, and a - // file with both gets both removed. - visitor.afterVisit.push(new RemoveAmdDependency(module)); -} // Type alias for RightPadded elements to simplify type signatures type RightPaddedElement = { diff --git a/rewrite-javascript/rewrite/src/javascript/templating/template.ts b/rewrite-javascript/rewrite/src/javascript/templating/template.ts index fdc0a9c69e..8bf21c2f9f 100644 --- a/rewrite-javascript/rewrite/src/javascript/templating/template.ts +++ b/rewrite-javascript/rewrite/src/javascript/templating/template.ts @@ -17,7 +17,7 @@ import {Cursor, Tree} from '../..'; import {J} from '../../java'; import {ApplyOptions, Parameter, TemplateOptions, TemplateParameter} from './types'; import {bindingContextStatement, isResolvable} from './bindings'; -import {maybeAddImport} from '../add-import'; +import {maybeAddImport} from '../binding'; import {JavaScriptVisitor} from '../visitor'; import {MatchResult} from './pattern'; import {generateCacheKey, globalAstCache, WRAPPERS_MAP_SYMBOL} from './utils'; diff --git a/rewrite-javascript/rewrite/test/javascript/add-import.test.ts b/rewrite-javascript/rewrite/test/javascript/add-import.test.ts index a7d45238b1..a6f5b73753 100644 --- a/rewrite-javascript/rewrite/test/javascript/add-import.test.ts +++ b/rewrite-javascript/rewrite/test/javascript/add-import.test.ts @@ -2954,10 +2954,13 @@ describe('AddImport visitor', () => { test('a name bound through any binding pattern is occupied without answering for the module', async () => { const spec = new RecipeSpec(); - const bound: string[] = []; + const bound: (string | undefined)[] = []; spec.recipe = fromVisitor(new class extends JavaScriptVisitor { override async visitJsCompilationUnit(cu: JS.CompilationUnit, p: any): Promise { bound.push(maybeAddImport(this, {module: 'm', member: 'b', onlyIfReferenced: false})); + // 'other' is already required here, so the file reads as CommonJS; a request + // for its module object (no member) shares maybeBind's refusal to gain an ES + // import there (ADR 0013). bound.push(maybeAddImport(this, {module: 'other', preferredName: 'z', onlyIfReferenced: false})); bound.push(maybeAddImport(this, {module: 'p', member: 'default', preferredName: 'e', onlyIfReferenced: false})); return super.visitJsCompilationUnit(cu, p); @@ -2974,7 +2977,6 @@ describe('AddImport visitor', () => { `, ` import {b as b_1} from 'm'; - import z from 'other'; import e_1 from 'p'; const {a: {b}} = require('other'); @@ -2985,7 +2987,7 @@ describe('AddImport visitor', () => { ) ); - expect(bound).toEqual(['b_1', 'z', 'e_1']); + expect(bound).toEqual(['b_1', undefined, 'e_1']); }); test('a merged specifier sorts by the name it binds, as its neighbours do', async () => { diff --git a/rewrite-javascript/rewrite/test/javascript/bind-module.test.ts b/rewrite-javascript/rewrite/test/javascript/binding.test.ts similarity index 92% rename from rewrite-javascript/rewrite/test/javascript/bind-module.test.ts rename to rewrite-javascript/rewrite/test/javascript/binding.test.ts index 5b6209e0d7..a36e43a27f 100644 --- a/rewrite-javascript/rewrite/test/javascript/bind-module.test.ts +++ b/rewrite-javascript/rewrite/test/javascript/binding.test.ts @@ -1,7 +1,7 @@ import {fromVisitor, RecipeSpec} from "../../src/test"; import { - JavaScriptVisitor, JS, javascript, typescript, moduleBindings, isAmdBlock, ModuleBindings, bindModule, - maybeRemoveImport, removeNewlyUnusedAmdBindings + JavaScriptVisitor, JS, javascript, typescript, moduleBindings, isAmdBlock, ModuleBindings, maybeBind, + maybeAddImport, maybeRemoveImport, removeNewlyUnusedAmdBindings } from "../../src/javascript"; import {emptySpace, J, rightPadded} from "../../src/java"; import {emptyMarkers} from "../../src/markers"; @@ -165,7 +165,7 @@ function identifierRef(name: string): J.Identifier { }; } -/** Rewrites `call` to read as `.call`, the way a real recipe uses the name `bindModule` returned. */ +/** Rewrites `call` to read as `.call`, the way a real recipe uses the name `maybeBind` returned. */ function withReference(call: J.MethodInvocation, name: string): J.MethodInvocation { return {...call, select: rightPadded(identifierRef(name), emptySpace)}; } @@ -177,7 +177,7 @@ function rebind(module: string, bound: {name?: string}) { if (m.name.simpleName !== "target") { return super.visitMethodInvocation(m, p); } - bound.name = bindModule(this, module); + bound.name = maybeBind(this, {module}); return bound.name === undefined ? m : withReference(m, bound.name); } }; @@ -190,13 +190,13 @@ function askAndAbandon(module: string, bound: {name?: string}) { if (m.name.simpleName !== "target") { return super.visitMethodInvocation(m, p); } - bound.name = bindModule(this, module); + bound.name = maybeBind(this, {module}); return m; } }; } -describe("bindModule", () => { +describe("maybeBind", () => { test("AMD appends to both lists and reports the new name", async () => { const spec = new RecipeSpec(); const bound: {name?: string} = {}; @@ -314,7 +314,7 @@ describe("bindModule", () => { return super.visitMethodInvocation(m, p); } const bound = bound1.name === undefined ? bound1 : bound2; - bound.name = bindModule(this, "sap/ui/core/Element"); + bound.name = maybeBind(this, {module: "sap/ui/core/Element"}); return bound.name === undefined ? m : withReference(m, bound.name); } }); @@ -333,11 +333,11 @@ describe("bindModule", () => { spec.recipe = fromVisitor(new class extends JavaScriptVisitor { override async visitMethodInvocation(m: J.MethodInvocation, p: any): Promise { if (m.name.simpleName === "targetA") { - boundA.name = bindModule(this, "a/Element"); + boundA.name = maybeBind(this, {module: "a/Element"}); return boundA.name === undefined ? m : withReference(m, boundA.name); } if (m.name.simpleName === "targetB") { - boundB.name = bindModule(this, "b/Element"); + boundB.name = maybeBind(this, {module: "b/Element"}); return boundB.name === undefined ? m : withReference(m, boundB.name); } return super.visitMethodInvocation(m, p); @@ -357,7 +357,7 @@ describe("bindModule", () => { spec.recipe = fromVisitor(new class extends JavaScriptVisitor { override async visitMethodInvocation(m: J.MethodInvocation, p: any): Promise { if (m.name.simpleName === "target") { - bound.name = bindModule(this, "sap/ui/core/Element"); + bound.name = maybeBind(this, {module: "sap/ui/core/Element"}); return m; } const visited = await super.visitMethodInvocation(m, p) as J.MethodInvocation; @@ -376,7 +376,7 @@ describe("bindModule", () => { spec.recipe = fromVisitor(new class extends JavaScriptVisitor { override async visitMethodInvocation(m: J.MethodInvocation, p: any): Promise { if (m.name.simpleName === "target") { - const name = bindModule(this, "sap/ui/core/Element"); + const name = maybeBind(this, {module: "sap/ui/core/Element"}); return name === undefined ? m : withReference(m, name); } const visited = await super.visitMethodInvocation(m, p) as J.MethodInvocation; @@ -458,6 +458,30 @@ describe("bindModule", () => { )); expect(bound.name).toBe("Element"); }); + + test("the deprecated maybeAddImport still works, returning what the equivalent maybeBind call would", async () => { + const bindWith = (bind: (visitor: JavaScriptVisitor) => string | undefined) => { + const bound: {name?: string} = {}; + const spec = new RecipeSpec(); + spec.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitMethodInvocation(m: J.MethodInvocation, p: any): Promise { + if (m.name.simpleName === "target") { + bound.name = bind(this); + } + return super.visitMethodInvocation(m, p); + } + }); + return spec.rewriteRun(typescript( + `target();`, + `import {readFile} from 'fs';\n\ntarget();` + )).then(() => bound.name); + }; + + const viaAddImport = await bindWith(v => maybeAddImport(v, {module: "fs", member: "readFile", onlyIfReferenced: false})); + const viaBind = await bindWith(v => maybeBind(v, {module: "fs", member: "readFile", onlyIfReferenced: false})); + expect(viaAddImport).toBe("readFile"); + expect(viaBind).toBe("readFile"); + }); }); function dropModule(module: string) { From 423c48e4040116e2d4cf5e07927a8eaa711c65b2 Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Thu, 27 Aug 2026 23:19:13 +0200 Subject: [PATCH 23/41] JavaScript: add maybeUnbind, mirroring maybeBind on the removal side Collapses maybeRemoveImport into a new maybeUnbind, following the same pattern used for maybeAddImport/maybeBind: maybeRemoveImport becomes a deprecated one-line delegation with its exact released signature. MaybeUnbindOptions stays its own small shape rather than reusing AddImportOptions, since none of that bag's add-side fields (alias, preferredName, onlyIfReferenced, sideEffectOnly, typeOnly, style, quoteStyle) mean anything when removing. maybeUnbind takes amdCallee, closing a gap noted in ADR 0013: the old maybeRemoveImport had no way to reach a custom AMD loader's blocks on the removal lane. Also fixes a bug a review caught in the code being moved: member was silently dropped when queuing the AMD-side removal, so a member-scoped request (which only makes sense on the ESM lane) took the whole dependency instead of leaving it alone. RemoveAmdDependency now takes member and no-ops when it's set, since a dependency binds a whole module and has nothing narrower to remove. --- .../rewrite/src/javascript/amd.ts | 12 ++++- .../rewrite/src/javascript/binding.ts | 30 ++++++++--- .../rewrite/src/javascript/index.ts | 4 +- .../rewrite/test/javascript/binding.test.ts | 51 +++++++++++++++++-- 4 files changed, 82 insertions(+), 15 deletions(-) diff --git a/rewrite-javascript/rewrite/src/javascript/amd.ts b/rewrite-javascript/rewrite/src/javascript/amd.ts index 14eafc39e4..8771538797 100644 --- a/rewrite-javascript/rewrite/src/javascript/amd.ts +++ b/rewrite-javascript/rewrite/src/javascript/amd.ts @@ -722,15 +722,23 @@ export class AddAmdDependency

extends JavaScriptVisitor

{ /** * The AMD counterpart to `RemoveImport`: drops a dependency the block's body no longer names, * along with the parameter bound to it. A dependency the factory takes no parameter for is - * loaded for its side effects and stays, matching `bindAmd`'s own read of such a block. + * loaded for its side effects and stays, matching `bindAmd`'s own read of such a block. A + * `member`-scoped request is a no-op here — see `maybeUnbind`. */ export class RemoveAmdDependency

extends JavaScriptVisitor

{ - constructor(readonly module: string, readonly callees: readonly string[] = DEFAULT_AMD_CALLEES) { + constructor( + readonly module: string, + readonly member?: string, + readonly callees: readonly string[] = DEFAULT_AMD_CALLEES + ) { super(); } override async visitMethodInvocation(m: J.MethodInvocation, p: P): Promise { const visited = await super.visitMethodInvocation(m, p) as J.MethodInvocation; + if (this.member !== undefined) { + return visited; + } const block = amdBlockOf(visited, this.callees); if (block === undefined) { return visited; diff --git a/rewrite-javascript/rewrite/src/javascript/binding.ts b/rewrite-javascript/rewrite/src/javascript/binding.ts index bddc46a5ee..5433cf0ab1 100644 --- a/rewrite-javascript/rewrite/src/javascript/binding.ts +++ b/rewrite-javascript/rewrite/src/javascript/binding.ts @@ -30,6 +30,16 @@ export interface MaybeBindOptions extends AddImportOptions { amdCallee?: string | readonly string[]; } +export interface MaybeUnbindOptions { + module: string; + + /** The member to remove; unset removes every unused binding of the module. */ + member?: string; + + /** Callees that introduce an AMD block. UI5 writes `sap.ui.define`, RequireJS and Dojo `define`. */ + amdCallee?: string | readonly string[]; +} + export interface ModuleBindings { /** The module `localName` refers to, or undefined when it is not a module binding. */ moduleOf(localName: string): string | undefined; @@ -237,20 +247,28 @@ export function maybeBind( /** * Removes `module`'s import(s) where unused, or one `member` of it — `'default'` and `'*'` select - * the default and namespace import regardless of local name. Applies to the ESM lane only; an AMD - * dependency binds a whole module, so `member` is ignored there. + * the default and namespace import regardless of local name. A member-scoped request does not + * apply to an AMD dependency, which binds a module rather than one of its members. */ -export function maybeRemoveImport(visitor: JavaScriptVisitor, module: string, member?: string) { +export function maybeUnbind(visitor: JavaScriptVisitor, options: MaybeUnbindOptions): void { for (const v of visitor.afterVisit || []) { - if (v instanceof RemoveImport && v.module === module && v.member === member) { + if (v instanceof RemoveImport && v.module === options.module && v.member === options.member) { return; } } - visitor.afterVisit.push(new RemoveImport(module, member)); + visitor.afterVisit.push(new RemoveImport(options.module, options.member)); // Both queue unconditionally so the caller need not know which lane the file uses: each // visitor removes whatever matching construct it finds, ESM import or AMD dependency, and a // file with both gets both removed. - visitor.afterVisit.push(new RemoveAmdDependency(module)); + visitor.afterVisit.push(new RemoveAmdDependency(options.module, options.member, calleesOf(options))); +} + +/** + * @deprecated Use {@link maybeUnbind} instead — this is a call-shape change, not a behaviour + * change: `maybeRemoveImport(v, module, member)` is `maybeUnbind(v, {module, member})`. + */ +export function maybeRemoveImport(visitor: JavaScriptVisitor, module: string, member?: string): void { + maybeUnbind(visitor, {module, member}); } /** diff --git a/rewrite-javascript/rewrite/src/javascript/index.ts b/rewrite-javascript/rewrite/src/javascript/index.ts index 241dcdd277..51824f46e1 100644 --- a/rewrite-javascript/rewrite/src/javascript/index.ts +++ b/rewrite-javascript/rewrite/src/javascript/index.ts @@ -40,8 +40,8 @@ export { DEFAULT_AMD_CALLEES, amdBlockOf, dependencyNames, parameterNames, withDependency, withoutDependencyAt, RemoveAmdDependency, removeNewlyUnusedAmdBindings } from "./amd"; -export type {MaybeBindOptions, ModuleBindings} from "./binding"; -export {maybeBind, moduleBindings, isAmdBlock, maybeRemoveImport, maybeAddImport} from "./binding"; +export type {MaybeBindOptions, MaybeUnbindOptions, ModuleBindings} from "./binding"; +export {maybeBind, maybeUnbind, moduleBindings, isAmdBlock, maybeRemoveImport, maybeAddImport} from "./binding"; export * from "./remove-import"; export * from "./cleanup/index"; export * from "./recipes/index"; diff --git a/rewrite-javascript/rewrite/test/javascript/binding.test.ts b/rewrite-javascript/rewrite/test/javascript/binding.test.ts index a36e43a27f..0930d77b45 100644 --- a/rewrite-javascript/rewrite/test/javascript/binding.test.ts +++ b/rewrite-javascript/rewrite/test/javascript/binding.test.ts @@ -1,7 +1,7 @@ import {fromVisitor, RecipeSpec} from "../../src/test"; import { JavaScriptVisitor, JS, javascript, typescript, moduleBindings, isAmdBlock, ModuleBindings, maybeBind, - maybeAddImport, maybeRemoveImport, removeNewlyUnusedAmdBindings + maybeAddImport, maybeUnbind, maybeRemoveImport, removeNewlyUnusedAmdBindings } from "../../src/javascript"; import {emptySpace, J, rightPadded} from "../../src/java"; import {emptyMarkers} from "../../src/markers"; @@ -484,16 +484,16 @@ describe("maybeBind", () => { }); }); -function dropModule(module: string) { +function dropModule(module: string, member?: string) { return new class extends JavaScriptVisitor { override async visitJsCompilationUnit(cu: JS.CompilationUnit, p: any): Promise { - maybeRemoveImport(this, module); + maybeUnbind(this, {module, member}); return super.visitJsCompilationUnit(cu, p); } }; } -describe("maybeRemoveImport on an AMD block", () => { +describe("maybeUnbind on an AMD block", () => { test("an unreferenced dependency goes with its parameter", async () => { const spec = new RecipeSpec(); spec.recipe = fromVisitor(dropModule("sap/m/Button")); @@ -518,9 +518,31 @@ describe("maybeRemoveImport on an AMD block", () => { `sap.ui.define(["a/Side"], function () {});` )); }); + + test("a member-scoped request does not apply, since a dependency binds a whole module", async () => { + const spec = new RecipeSpec(); + spec.recipe = fromVisitor(dropModule("sap/m/Button", "default")); + await spec.rewriteRun(javascript( + `sap.ui.define(["sap/m/Button"], function (Button) {});` + )); + }); + + test("a custom amdCallee reaches the removal lane", async () => { + const spec = new RecipeSpec(); + spec.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitJsCompilationUnit(cu: JS.CompilationUnit, p: any): Promise { + maybeUnbind(this, {module: "sap/m/Button", amdCallee: "myDefine"}); + return super.visitJsCompilationUnit(cu, p); + } + }); + await spec.rewriteRun(javascript( + `myDefine(["sap/m/Button", "a/C"], function (Button, C) { C.f(); });`, + `myDefine(["a/C"], function (C) { C.f(); });` + )); + }); }); -describe("maybeRemoveImport on an ESM file", () => { +describe("maybeUnbind on an ESM file", () => { test("removes the import; the AMD lane it also queues finds no block to act on", async () => { const spec = new RecipeSpec(); spec.recipe = fromVisitor(dropModule("sap/m/Button")); @@ -529,6 +551,25 @@ describe("maybeRemoveImport on an ESM file", () => { `console.log(1);` )); }); + + test("the deprecated maybeRemoveImport still works, doing what the equivalent maybeUnbind call would", async () => { + const removeWith = (remove: (visitor: JavaScriptVisitor) => void) => { + const spec = new RecipeSpec(); + spec.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitJsCompilationUnit(cu: JS.CompilationUnit, p: any): Promise { + remove(this); + return super.visitJsCompilationUnit(cu, p); + } + }); + return spec.rewriteRun(typescript( + `import Button from "sap/m/Button";\n\nconsole.log(1);`, + `console.log(1);` + )); + }; + + await removeWith(v => maybeRemoveImport(v, "sap/m/Button")); + await removeWith(v => maybeUnbind(v, {module: "sap/m/Button"})); + }); }); /** Renames a call's `select` identifier per `renames`, then sweeps whatever the rename orphaned. */ From 9a71d10fce0e2560fa83fbc4fe85fa102d50da7f Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Fri, 28 Aug 2026 02:34:55 +0200 Subject: [PATCH 24/41] JavaScript: refuse CommonJS creation symmetrically, fix .mjs/.mts detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit maybeBind's CommonJS refusal only ever covered whole-module requests: the bindingOf-based shortcut it inherited from bindModule was gated on isWholeModule, so a member request skipped the check entirely and went straight to bindImport, which would happily queue `import {x} from "m"` into a file that otherwise only uses require() — code that throws in Node. A whole-module request correctly refused. bindImport now takes an optional refuseCreate flag, checked at every point that would queue a new AddImport, placed after its own moduleScopeBindings reuse lookup so an existing binding (including one made via a destructured require) still answers the request first. maybeBind drops its separate bindingOf shortcut - reuse was already a strict subset of what bindImport's own lookup covers - and computes refuseCreate uniformly for every request shape. This changes released maybeAddImport's behaviour in one case, deliberately: a member request that used to create an import on a CommonJS file now returns undefined, same as a whole-module request always has. isCommonJs also gains a `.mjs`/`.mts` special case, matching how AddImport's own determineImportStyle already reads those extensions as ES6-preferring - without it, a `.mjs` file containing a require() call would have started refusing under the fix above despite Node treating it as an ES module unconditionally. --- .../rewrite/src/javascript/add-import.ts | 19 ++++++++- .../rewrite/src/javascript/binding.ts | 23 +++++------ .../test/javascript/add-import.test.ts | 10 ++--- .../rewrite/test/javascript/binding.test.ts | 41 +++++++++++++++++++ 4 files changed, 72 insertions(+), 21 deletions(-) diff --git a/rewrite-javascript/rewrite/src/javascript/add-import.ts b/rewrite-javascript/rewrite/src/javascript/add-import.ts index b25561934a..ea82494876 100644 --- a/rewrite-javascript/rewrite/src/javascript/add-import.ts +++ b/rewrite-javascript/rewrite/src/javascript/add-import.ts @@ -68,11 +68,13 @@ export interface AddImportOptions { /** * Ensures an import for `options.module` exists, queuing an `AddImport` edit where none already * serves the request. `maybeBind`'s ESM/CommonJS lane is this function; its own JSDoc carries the - * return-value contract. + * return-value contract. `refuseCreate` answers `undefined` instead of queuing a new import, + * without affecting whether an existing binding answers the request first. */ export function bindImport( visitor: JavaScriptVisitor, - options: AddImportOptions + options: AddImportOptions, + refuseCreate?: boolean ): string | undefined { validate(options); const module = moduleNameOf(options.module); @@ -97,12 +99,18 @@ export function bindImport( } if (sideEffectOnly) { + if (refuseCreate) { + return undefined; + } visitor.afterVisit.push(new AddImport(options)); return undefined; } // A pinned alias is the whole answer, so the file has no say and nothing below needs to read it. if (options.alias) { + if (refuseCreate) { + return undefined; + } visitor.afterVisit.push(new AddImport(options, options.alias)); return options.alias; } @@ -111,6 +119,9 @@ export function bindImport( const cursor = cursorOf(visitor); const cu = cursor && compilationUnitOf(cursor); if (!cu) { + if (refuseCreate) { + return undefined; + } visitor.afterVisit.push(new AddImport(options, derived)); return derived; } @@ -128,6 +139,10 @@ export function bindImport( } } + if (refuseCreate) { + return undefined; + } + // Only the module scope answers for a name, but any scope in the file occupies one. The queue // gives every later request for this module the name chosen here, so it has to clear the scopes // those references will sit in, which are not known yet. diff --git a/rewrite-javascript/rewrite/src/javascript/binding.ts b/rewrite-javascript/rewrite/src/javascript/binding.ts index 5433cf0ab1..19583bc850 100644 --- a/rewrite-javascript/rewrite/src/javascript/binding.ts +++ b/rewrite-javascript/rewrite/src/javascript/binding.ts @@ -128,6 +128,12 @@ function isCommonJs(cu: JS.CompilationUnit): boolean { if (cu.sourcePath.endsWith(".cjs")) { return true; } + // Node treats these as ES modules regardless of what they contain, the same way + // `AddImport`'s own `determineImportStyle` reads them as ES6-preferring; `.js`/`.ts`/`.tsx` + // stay ambiguous and fall through to the statements below. + if (cu.sourcePath.endsWith(".mjs") || cu.sourcePath.endsWith(".mts")) { + return false; + } let requires = false; for (const stmt of cu.statements) { if (stmt.element?.kind === JS.Kind.Import) { @@ -226,23 +232,14 @@ export function maybeBind( return bindAmd(visitor, amd, module, options.preferredName, namesInScope, calleesOf(options)); } - const bindings = moduleBindings(visitor, options); + // `bindImport` finds and reuses an existing binding on its own — including one made via + // `require` — so refusal here only has to gate the point where it would create a new one. const isWholeModule = !options.sideEffectOnly && (options.member === undefined || options.member === "*"); - if (isWholeModule) { - const bound = bindings.bindingOf(module); - if (bound !== undefined) { - return bound; - } - if (bindings.moduleSystem === "commonjs") { - // A `require` answers for a module it already binds, but creating one is - // unimplemented, and an `import` in a file that has none would be the wrong form. - return undefined; - } - } + const refuseCreate = moduleBindings(visitor, options).moduleSystem === "commonjs"; return bindImport(visitor, { ...options, preferredName: options.preferredName ?? (isWholeModule ? lastSegment(module) : undefined) - }); + }, refuseCreate); } /** diff --git a/rewrite-javascript/rewrite/test/javascript/add-import.test.ts b/rewrite-javascript/rewrite/test/javascript/add-import.test.ts index a6f5b73753..5e5e291236 100644 --- a/rewrite-javascript/rewrite/test/javascript/add-import.test.ts +++ b/rewrite-javascript/rewrite/test/javascript/add-import.test.ts @@ -2958,9 +2958,6 @@ describe('AddImport visitor', () => { spec.recipe = fromVisitor(new class extends JavaScriptVisitor { override async visitJsCompilationUnit(cu: JS.CompilationUnit, p: any): Promise { bound.push(maybeAddImport(this, {module: 'm', member: 'b', onlyIfReferenced: false})); - // 'other' is already required here, so the file reads as CommonJS; a request - // for its module object (no member) shares maybeBind's refusal to gain an ES - // import there (ADR 0013). bound.push(maybeAddImport(this, {module: 'other', preferredName: 'z', onlyIfReferenced: false})); bound.push(maybeAddImport(this, {module: 'p', member: 'default', preferredName: 'e', onlyIfReferenced: false})); return super.visitJsCompilationUnit(cu, p); @@ -2970,16 +2967,17 @@ describe('AddImport visitor', () => { await spec.rewriteRun( typescript( ` - const {a: {b}} = require('other'); + const {a: {b}} = getOther(); const [e] = window; b(); `, ` import {b as b_1} from 'm'; + import z from 'other'; import e_1 from 'p'; - const {a: {b}} = require('other'); + const {a: {b}} = getOther(); const [e] = window; b(); @@ -2987,7 +2985,7 @@ describe('AddImport visitor', () => { ) ); - expect(bound).toEqual(['b_1', undefined, 'e_1']); + expect(bound).toEqual(['b_1', 'z', 'e_1']); }); test('a merged specifier sorts by the name it binds, as its neighbours do', async () => { diff --git a/rewrite-javascript/rewrite/test/javascript/binding.test.ts b/rewrite-javascript/rewrite/test/javascript/binding.test.ts index 0930d77b45..18e4642a28 100644 --- a/rewrite-javascript/rewrite/test/javascript/binding.test.ts +++ b/rewrite-javascript/rewrite/test/javascript/binding.test.ts @@ -448,6 +448,47 @@ describe("maybeBind", () => { expect(bound.name).toBeUndefined(); }); + test("a member request refuses on a CommonJS file but still creates on an ESM file", async () => { + const memberBind = (bound: {value?: string}) => new class extends JavaScriptVisitor { + override async visitJsCompilationUnit(cu: JS.CompilationUnit, p: any): Promise { + bound.value = maybeBind(this, {module: "fs", member: "readFile", onlyIfReferenced: false}); + return super.visitJsCompilationUnit(cu, p); + } + }; + + const onCommonJs = new RecipeSpec(); + const commonJsName: {value?: string} = {}; + onCommonJs.recipe = fromVisitor(memberBind(commonJsName)); + await onCommonJs.rewriteRun(javascript( + `const other = require("a/Other");\n\ntarget();` + )); + + const onEsm = new RecipeSpec(); + const esmName: {value?: string} = {}; + onEsm.recipe = fromVisitor(memberBind(esmName)); + await onEsm.rewriteRun(typescript( + `target();`, + `import {readFile} from 'fs';\n\ntarget();` + )); + + expect(commonJsName.value).toBeUndefined(); + expect(esmName.value).toBe("readFile"); + }); + + test("a .mjs file with a require call still creates an import", async () => { + const spec = new RecipeSpec(); + const bound: {name?: string} = {}; + spec.recipe = fromVisitor(rebind("sap/ui/core/Element", bound)); + await spec.rewriteRun({ + ...javascript( + `const other = require("a/Other");\n\ntarget();`, + `import Element from "sap/ui/core/Element";\n\nconst other = require("a/Other");\n\nElement.target();` + ), + path: "module.mjs" + }); + expect(bound.name).toBe("Element"); + }); + test("a nested require callback is the block a binding belongs to", async () => { const spec = new RecipeSpec(); const bound: {name?: string} = {}; From 79675bbf80096ff90a2117557afca4c91889f559 Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Fri, 28 Aug 2026 02:59:59 +0200 Subject: [PATCH 25/41] JavaScript: distinguish default and namespace binding shapes in maybeBind Removing maybeBind's bindingOf shortcut in the previous round changed behaviour for a namespace import answering a bare {module} request: it stopped reusing and started creating instead. That outcome is correct - a namespace object's default sits at .default, so a namespace binding can't stand in for a default request - but it happened by accident, since bindImport's own reuse lookup records a namespace import's member as '*' and a require's as undefined, neither of which line up with a bare request's memberName(undefined) for the right reason. Gives ModuleObjectBinding a shape ("default" | "namespace" | "require"), set at each of moduleObjectBindings' three collection sites, and restores a reuse lookup in maybeBind ahead of bindImport that honours it: require answers either whole-module form since CommonJS has no default/namespace split, default and namespace each answer only their own. This also closes a second gap in the same family that no test had exercised: a require binding didn't answer a namespace-form request either, for the same underlying reason. ModuleBindings.bindingOf is unchanged - it still answers with any shape's local name - and its doc now says so: what a name binds and what it can substitute for are different questions, and only the second is maybeBind's concern. --- .../rewrite/src/javascript/binding.ts | 37 ++++++++++++++--- .../rewrite/test/javascript/binding.test.ts | 40 +++++++++++++++++++ 2 files changed, 71 insertions(+), 6 deletions(-) diff --git a/rewrite-javascript/rewrite/src/javascript/binding.ts b/rewrite-javascript/rewrite/src/javascript/binding.ts index 19583bc850..9e6b2b1feb 100644 --- a/rewrite-javascript/rewrite/src/javascript/binding.ts +++ b/rewrite-javascript/rewrite/src/javascript/binding.ts @@ -44,7 +44,11 @@ export interface ModuleBindings { /** The module `localName` refers to, or undefined when it is not a module binding. */ moduleOf(localName: string): string | undefined; - /** The local name bound to `module`, or undefined when nothing binds it. */ + /** + * The local name bound to `module`, or undefined when nothing binds it — of any shape: a + * namespace import counts, even though `maybeBind` will not treat it as answering a plain + * `{module}` request. What a name can stand in for is `maybeBind`'s question, not this one's. + */ bindingOf(module: string): string | undefined; /** @@ -121,6 +125,13 @@ function hasEsmSyntax(cu: JS.CompilationUnit): boolean { interface ModuleObjectBinding { name: string; module: string; + + /** + * `"default"` and `"namespace"` bind different values — a namespace object's default sits at + * `.default` — so only one answers a whole-module request of the matching form. CommonJS has + * no such split: `"require"` answers either. + */ + shape: "default" | "namespace" | "require"; } /** Whether the file binds its modules with `require`, which decides whether a create is possible. */ @@ -187,7 +198,7 @@ function moduleObjectBindings(cu: JS.CompilationUnit): ModuleObjectBinding[] { } const clause = jsImport.importClause; if (clause?.name?.element?.kind === J.Kind.Identifier) { - bindings.push({name: (clause.name.element as J.Identifier).simpleName, module}); + bindings.push({name: (clause.name.element as J.Identifier).simpleName, module, shape: "default"}); } // `namedBindings` is a `JS.Alias` only for `import * as X from "m"`; a renamed // named import (`{a as b}`) nests its alias inside `NamedImports` instead. @@ -195,7 +206,7 @@ function moduleObjectBindings(cu: JS.CompilationUnit): ModuleObjectBinding[] { if (named?.kind === JS.Kind.Alias) { const alias = (named as JS.Alias).alias; if (alias?.kind === J.Kind.Identifier) { - bindings.push({name: (alias as J.Identifier).simpleName, module}); + bindings.push({name: (alias as J.Identifier).simpleName, module, shape: "namespace"}); } } } @@ -204,12 +215,17 @@ function moduleObjectBindings(cu: JS.CompilationUnit): ModuleObjectBinding[] { const name = module === undefined ? undefined : (stmt.element as J.VariableDeclarations).variables[0]?.element?.name; if (module !== undefined && name?.kind === J.Kind.Identifier) { - bindings.push({name: (name as J.Identifier).simpleName, module}); + bindings.push({name: (name as J.Identifier).simpleName, module, shape: "require"}); } } return bindings; } +/** Whether `binding`'s shape satisfies a whole-module request for the namespace form when `wantsNamespace`. */ +function answersWholeModuleRequest(binding: ModuleObjectBinding, wantsNamespace: boolean): boolean { + return binding.shape === "require" || binding.shape === (wantsNamespace ? "namespace" : "default"); +} + /** * A local binding for `module` or one of its members, creating one where none exists, or * `undefined` where no safe binding is possible. The lane — AMD or ESM/CommonJS — is decided @@ -232,9 +248,18 @@ export function maybeBind( return bindAmd(visitor, amd, module, options.preferredName, namesInScope, calleesOf(options)); } - // `bindImport` finds and reuses an existing binding on its own — including one made via - // `require` — so refusal here only has to gate the point where it would create a new one. const isWholeModule = !options.sideEffectOnly && (options.member === undefined || options.member === "*"); + if (isWholeModule) { + const cu = compilationUnitOf(visitor); + const bound = cu && moduleObjectBindings(cu).find(b => + b.module === module && answersWholeModuleRequest(b, options.member === "*")); + if (bound !== undefined) { + return bound.name; + } + } + + // `bindImport`'s own lookup finds and reuses a member-specific binding on its own, so + // refusal here only has to gate the point where it would create a new one. const refuseCreate = moduleBindings(visitor, options).moduleSystem === "commonjs"; return bindImport(visitor, { ...options, diff --git a/rewrite-javascript/rewrite/test/javascript/binding.test.ts b/rewrite-javascript/rewrite/test/javascript/binding.test.ts index 18e4642a28..70a474154b 100644 --- a/rewrite-javascript/rewrite/test/javascript/binding.test.ts +++ b/rewrite-javascript/rewrite/test/javascript/binding.test.ts @@ -427,6 +427,46 @@ describe("maybeBind", () => { expect(bound.name).toBe("Elem"); }); + test("a namespace import answers a namespace request but not a plain module request", async () => { + const namespaceRequest = new RecipeSpec(); + const namespaceName: {value?: string} = {}; + namespaceRequest.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitJsCompilationUnit(cu: JS.CompilationUnit, p: any): Promise { + namespaceName.value = maybeBind(this, {module: "sap/m/Button", member: "*"}); + return super.visitJsCompilationUnit(cu, p); + } + }); + await namespaceRequest.rewriteRun(typescript( + `import * as Button from "sap/m/Button";\n\ntarget();` + )); + + const bareRequest = new RecipeSpec(); + const bound: {name?: string} = {}; + bareRequest.recipe = fromVisitor(rebind("sap/m/Button", bound)); + await bareRequest.rewriteRun(typescript( + `import * as Button from "sap/m/Button";\n\ntarget();`, + `import * as Button from "sap/m/Button";\nimport Button_1 from "sap/m/Button";\n\nButton_1.target();` + )); + + expect(namespaceName.value).toBe("Button"); + expect(bound.name).toBe("Button_1"); + }); + + test("a require binding answers a namespace request too, since CommonJS has no default/namespace split", async () => { + const spec = new RecipeSpec(); + const bound: {name?: string} = {}; + spec.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitJsCompilationUnit(cu: JS.CompilationUnit, p: any): Promise { + bound.name = maybeBind(this, {module: "sap/ui/core/Element", member: "*"}); + return super.visitJsCompilationUnit(cu, p); + } + }); + await spec.rewriteRun(javascript( + `const Elem = require("sap/ui/core/Element");\n\ntarget();` + )); + expect(bound.name).toBe("Elem"); + }); + test("a CommonJS file answers with the binding its require already has", async () => { const spec = new RecipeSpec(); const bound: {name?: string} = {}; From 3f0bac9e68dac0a4fc3431b548d3cf2a47c8b78e Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Fri, 28 Aug 2026 03:17:54 +0200 Subject: [PATCH 26/41] JavaScript: recognise dynamic import and top-level await in maybeBind const Button = await import("m") was invisible to moduleBindings - bindingOf, moduleOf both undefined, moduleSystem "none" - so a caller declined edits it should have made, and maybeBind couldn't reuse or correctly refuse a binding made this way. dynamicallyImportedModule recognises a top-level const X = await import("m"), mirroring requiredModule. import(...) is a keyword, not an identifier, so the parser maps it to JS.FunctionCall (callee on .function) rather than the J.MethodInvocation an ordinary call gets. A dynamic import resolves to the module namespace object, the same value import * as X binds, so moduleObjectBindings records it with shape: "namespace" rather than inventing a fourth shape. Factored the require and dynamic-import collection loops into one wholeModuleBindingsVia helper to avoid a third near-copy. hasEsmSyntax gains a hasTopLevelAwait(cu) clause: an await inside an async function says nothing about the file, but one that isn't nested inside a function of its own is legal only at module top level. Walks cu.statements stopping at any MethodDeclaration/Lambda boundary, the same shape scope.ts's hoistedNames already uses; exported scope.ts's walk helper to reuse rather than reimplement, and narrowed index.ts's scope export to an explicit list so walk doesn't leak into the public API. --- .../rewrite/src/javascript/binding.ts | 77 ++++++++++++++++--- .../rewrite/src/javascript/index.ts | 3 +- .../rewrite/src/javascript/scope.ts | 2 +- .../rewrite/test/javascript/binding.test.ts | 50 ++++++++++++ 4 files changed, 121 insertions(+), 11 deletions(-) diff --git a/rewrite-javascript/rewrite/src/javascript/binding.ts b/rewrite-javascript/rewrite/src/javascript/binding.ts index 9e6b2b1feb..54175ccdd1 100644 --- a/rewrite-javascript/rewrite/src/javascript/binding.ts +++ b/rewrite-javascript/rewrite/src/javascript/binding.ts @@ -17,7 +17,7 @@ import {J} from "../java"; import {JS} from "./tree"; import {Cursor} from "../tree"; import {JavaScriptVisitor} from "./visitor"; -import {scopeOf} from "./scope"; +import {scopeOf, walk} from "./scope"; import {AddImportOptions, bindImport, moduleNameOf} from "./add-import"; import {RemoveImport} from "./remove-import"; import { @@ -119,7 +119,26 @@ function hasEsmSyntax(cu: JS.CompilationUnit): boolean { element?.kind === JS.Kind.ExportDeclaration || element?.kind === JS.Kind.ExportAssignment || (modifiers?.some(m => m.keyword === "export") ?? false); + }) || hasTopLevelAwait(cu); +} + +/** + * Whether a statement holds an `await` outside any function of its own — legal only at module + * top level, unlike an `await` inside an `async function`, which says nothing about the file. + */ +function hasTopLevelAwait(cu: JS.CompilationUnit): boolean { + let found = false; + walk(cu.statements, node => { + if (found) { + return false; + } + if (node.kind === JS.Kind.Await) { + found = true; + return false; + } + return node.kind !== J.Kind.MethodDeclaration && node.kind !== J.Kind.Lambda; }); + return found; } interface ModuleObjectBinding { @@ -179,6 +198,52 @@ function requiredModule(statement: J | undefined): string | undefined { : undefined; } +/** + * The module a top-level `const X = await import("m")` names, for the one variable it declares. + * A dynamic import resolves to the module namespace object, the same value `import * as X` + * binds, so it shares that shape rather than getting one of its own. + */ +function dynamicallyImportedModule(statement: J | undefined): string | undefined { + if (statement?.kind !== J.Kind.VariableDeclarations) { + return undefined; + } + const variables = (statement as J.VariableDeclarations).variables; + const initializer = variables.length === 1 ? variables[0].element?.initializer?.element : undefined; + const awaited = initializer?.kind === JS.Kind.Await ? (initializer as JS.Await).expression : undefined; + // `import(...)` is a keyword, not an identifier, so the parser maps it to `JS.FunctionCall` + // rather than the `J.MethodInvocation` an ordinary call gets. + if (awaited?.kind !== JS.Kind.FunctionCall) { + return undefined; + } + const call = awaited as JS.FunctionCall; + const callee = call.function?.element; + if (callee?.kind !== J.Kind.Identifier || (callee as J.Identifier).simpleName !== "import") { + return undefined; + } + const argument = call.arguments.elements[0]?.element; + return argument?.kind === J.Kind.Literal && typeof (argument as J.Literal).value === "string" + ? (argument as J.Literal).value as string + : undefined; +} + +/** Bindings from top-level `const X = ` statements, all of one shape. */ +function wholeModuleBindingsVia( + cu: JS.CompilationUnit, + moduleOf: (statement: J | undefined) => string | undefined, + shape: ModuleObjectBinding["shape"] +): ModuleObjectBinding[] { + const bindings: ModuleObjectBinding[] = []; + for (const stmt of cu.statements) { + const module = moduleOf(stmt.element); + const name = module === undefined ? undefined : + (stmt.element as J.VariableDeclarations).variables[0]?.element?.name; + if (module !== undefined && name?.kind === J.Kind.Identifier) { + bindings.push({name: (name as J.Identifier).simpleName, module, shape}); + } + } + return bindings; +} + /** Only whole-module bindings: a named member does not name the module object. */ function moduleObjectBindings(cu: JS.CompilationUnit): ModuleObjectBinding[] { const bindings: ModuleObjectBinding[] = []; @@ -210,14 +275,8 @@ function moduleObjectBindings(cu: JS.CompilationUnit): ModuleObjectBinding[] { } } } - for (const stmt of cu.statements) { - const module = requiredModule(stmt.element); - const name = module === undefined ? undefined : - (stmt.element as J.VariableDeclarations).variables[0]?.element?.name; - if (module !== undefined && name?.kind === J.Kind.Identifier) { - bindings.push({name: (name as J.Identifier).simpleName, module, shape: "require"}); - } - } + bindings.push(...wholeModuleBindingsVia(cu, requiredModule, "require")); + bindings.push(...wholeModuleBindingsVia(cu, dynamicallyImportedModule, "namespace")); return bindings; } diff --git a/rewrite-javascript/rewrite/src/javascript/index.ts b/rewrite-javascript/rewrite/src/javascript/index.ts index 51824f46e1..8384f1da71 100644 --- a/rewrite-javascript/rewrite/src/javascript/index.ts +++ b/rewrite-javascript/rewrite/src/javascript/index.ts @@ -31,7 +31,8 @@ export * from "./autodetect"; export * from "./tree-debug"; export * from "./project-parser"; -export * from "./scope"; +export type {Scope} from "./scope"; +export {scopeOf, namesDeclaredIn, bindingNames} from "./scope"; export type {QuoteChar, AddImportOptions} from "./add-import"; export {ImportStyle, moduleNameOf, AddImport} from "./add-import"; // AMD mechanics `recipes-ui5` builds on directly, beyond the `maybeBind` surface below. diff --git a/rewrite-javascript/rewrite/src/javascript/scope.ts b/rewrite-javascript/rewrite/src/javascript/scope.ts index 5c95ea0acb..7f27ce7326 100644 --- a/rewrite-javascript/rewrite/src/javascript/scope.ts +++ b/rewrite-javascript/rewrite/src/javascript/scope.ts @@ -268,7 +268,7 @@ function hoistedNames(scope: any): string[] { } /** Visits every LST node under `node`, leaving a subtree unvisited where `visit` returns false. */ -function walk(node: unknown, visit: (node: any) => boolean): void { +export function walk(node: unknown, visit: (node: any) => boolean): void { if (Array.isArray(node)) { node.forEach(child => walk(child, visit)); return; diff --git a/rewrite-javascript/rewrite/test/javascript/binding.test.ts b/rewrite-javascript/rewrite/test/javascript/binding.test.ts index 70a474154b..07fbc8140b 100644 --- a/rewrite-javascript/rewrite/test/javascript/binding.test.ts +++ b/rewrite-javascript/rewrite/test/javascript/binding.test.ts @@ -56,6 +56,14 @@ describe("moduleBindings", () => { expect(seen.moduleSystem).toBe("esm"); }); + test("a top-level await import is the file's only module syntax and reports \"esm\"", async () => { + const spec = new RecipeSpec(); + const seen: {moduleSystem?: string} = {}; + spec.recipe = fromVisitor(captureBindings(seen)); + await spec.rewriteRun(typescript(`const Button = await import("sap/m/Button");`)); + expect(seen.moduleSystem).toBe("esm"); + }); + test("a selected require, unlike a bare one, does not read as a CommonJS binding", async () => { const spec = new RecipeSpec(); const seen: {moduleSystem?: string} = {}; @@ -467,6 +475,48 @@ describe("maybeBind", () => { expect(bound.name).toBe("Elem"); }); + test("a whole-module await import answers a namespace request but not a plain module request", async () => { + const namespaceRequest = new RecipeSpec(); + const namespaceName: {value?: string} = {}; + namespaceRequest.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitJsCompilationUnit(cu: JS.CompilationUnit, p: any): Promise { + namespaceName.value = maybeBind(this, {module: "sap/m/Button", member: "*"}); + return super.visitJsCompilationUnit(cu, p); + } + }); + await namespaceRequest.rewriteRun(typescript( + `const Button = await import("sap/m/Button");\n\ntarget();` + )); + + const bareRequest = new RecipeSpec(); + const bound: {name?: string} = {}; + bareRequest.recipe = fromVisitor(rebind("sap/m/Button", bound)); + await bareRequest.rewriteRun(typescript( + `const Button = await import("sap/m/Button");\n\ntarget();`, + `import Button_1 from "sap/m/Button";\n\nconst Button = await import("sap/m/Button");\n\nButton_1.target();` + )); + + expect(namespaceName.value).toBe("Button"); + expect(bound.name).toBe("Button_1"); + }); + + test("a destructured await import answers neither request but still occupies its name", async () => { + const spec = new RecipeSpec(); + const bound: (string | undefined)[] = []; + spec.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitJsCompilationUnit(cu: JS.CompilationUnit, p: any): Promise { + bound.push(maybeBind(this, {module: "sap/m/Button", onlyIfReferenced: false})); + bound.push(maybeBind(this, {module: "sap/m/Button", member: "*", onlyIfReferenced: false})); + return super.visitJsCompilationUnit(cu, p); + } + }); + await spec.rewriteRun(typescript( + `const {Button} = await import("sap/m/Button");\n\ntarget();`, + `import Button_1 from "sap/m/Button";\nimport * as Button_2 from "sap/m/Button";\n\nconst {Button} = await import("sap/m/Button");\n\ntarget();` + )); + expect(bound).toEqual(["Button_1", "Button_2"]); + }); + test("a CommonJS file answers with the binding its require already has", async () => { const spec = new RecipeSpec(); const bound: {name?: string} = {}; From d179c9faffe1d345d111117b1671217592dc9b82 Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Fri, 28 Aug 2026 03:31:49 +0200 Subject: [PATCH 27/41] JavaScript: three small fixes from an earlier binding review moduleBindings reported moduleSystem: "esm" when the cursor reached no compilation unit - a definite answer from no information - instead of "none", the value a caller checks for to avoid turning a plain script into a module. parameterSlot.withPrefix in amd.ts read declaration.variables[0] with no undefined check, while its two siblings in the same file (identifierOf, foldDeclarationTrailingSpace) both guard against a J.VariableDeclarations with an empty variables list - a shape the parser does produce, just not reachable through this call path for a real function parameter as far as I could construct. Made it consistent with its siblings regardless. references' doc restated its own first sentence ("counts any identifier of that name, so it matches any identifier of that name") - trimmed to keep the field-access caveat and its rationale. --- rewrite-javascript/rewrite/src/javascript/amd.ts | 12 +++++++----- .../rewrite/src/javascript/binding.ts | 2 +- .../rewrite/test/javascript/binding.test.ts | 13 +++++++++++++ 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/rewrite-javascript/rewrite/src/javascript/amd.ts b/rewrite-javascript/rewrite/src/javascript/amd.ts index 8771538797..a572f53244 100644 --- a/rewrite-javascript/rewrite/src/javascript/amd.ts +++ b/rewrite-javascript/rewrite/src/javascript/amd.ts @@ -221,6 +221,9 @@ const parameterSlot: Slot = { } const declaration = element as J.VariableDeclarations; const variable = declaration.variables[0]; + if (variable === undefined) { + return element; + } return { ...declaration, variables: [ @@ -621,11 +624,10 @@ export function bodyOf(block: AmdBlock): J | undefined { } /** - * Whether the factory body references `name`. This counts any identifier of that name, so it - * matches any identifier of that name, including a member in a field access, since a stray - * dependency is fine where a shadowed one is not. `missingBodyAnswer` covers a body-less - * factory: `false` for an addition (nothing to conflict with), `true` for a removal (nothing - * to prove the binding unused). + * Whether the factory body references `name`. This counts any identifier of that name, + * including a member in a field access, since a stray dependency is fine where a shadowed one + * is not. `missingBodyAnswer` covers a body-less factory: `false` for an addition (nothing to + * conflict with), `true` for a removal (nothing to prove the binding unused). */ export async function references(block: AmdBlock, name: string, missingBodyAnswer: boolean): Promise { const body = bodyOf(block); diff --git a/rewrite-javascript/rewrite/src/javascript/binding.ts b/rewrite-javascript/rewrite/src/javascript/binding.ts index 54175ccdd1..23d4744c17 100644 --- a/rewrite-javascript/rewrite/src/javascript/binding.ts +++ b/rewrite-javascript/rewrite/src/javascript/binding.ts @@ -94,7 +94,7 @@ export function moduleBindings( const cu = compilationUnitOf(visitor); const bound = cu === undefined ? [] : moduleObjectBindings(cu); return { - moduleSystem: cu === undefined ? "esm" : + moduleSystem: cu === undefined ? "none" : isCommonJs(cu) ? "commonjs" : hasEsmSyntax(cu) ? "esm" : "none", moduleOf: localName => bound.find(b => b.name === localName)?.module, diff --git a/rewrite-javascript/rewrite/test/javascript/binding.test.ts b/rewrite-javascript/rewrite/test/javascript/binding.test.ts index 07fbc8140b..5397834399 100644 --- a/rewrite-javascript/rewrite/test/javascript/binding.test.ts +++ b/rewrite-javascript/rewrite/test/javascript/binding.test.ts @@ -48,6 +48,19 @@ describe("moduleBindings", () => { expect(seen.moduleSystem).toBe("none"); }); + test("no compilation unit on the cursor reports \"none\", not a lane it cannot know", async () => { + const spec = new RecipeSpec(); + const seen: {moduleSystem?: string} = {}; + spec.recipe = fromVisitor(new class extends JavaScriptVisitor { + constructor() { + super(); + seen.moduleSystem = moduleBindings(this).moduleSystem; + } + }); + await spec.rewriteRun(typescript(`const x = 1;`)); + expect(seen.moduleSystem).toBe("none"); + }); + test("an export alone is enough to read as \"esm\", even with no import", async () => { const spec = new RecipeSpec(); const seen: {moduleSystem?: string} = {}; From 2faf6de202099b8bf16bbcc9dfd04ce09bb7fcb5 Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Fri, 28 Aug 2026 03:46:51 +0200 Subject: [PATCH 28/41] JavaScript: moduleOf carries the same shape caveat bindingOf does --- rewrite-javascript/rewrite/src/javascript/binding.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/rewrite-javascript/rewrite/src/javascript/binding.ts b/rewrite-javascript/rewrite/src/javascript/binding.ts index 23d4744c17..09ee59a203 100644 --- a/rewrite-javascript/rewrite/src/javascript/binding.ts +++ b/rewrite-javascript/rewrite/src/javascript/binding.ts @@ -41,7 +41,11 @@ export interface MaybeUnbindOptions { } export interface ModuleBindings { - /** The module `localName` refers to, or undefined when it is not a module binding. */ + /** + * The module `localName` refers to, or undefined when it is not a module binding — of any + * shape, so a namespace import's name answers here even though `maybeBind` will not reuse it + * for a plain `{module}` request. See `bindingOf`. + */ moduleOf(localName: string): string | undefined; /** From 484c37c962b02cf956dfb201f6465f0205426820 Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Fri, 28 Aug 2026 04:16:38 +0200 Subject: [PATCH 29/41] JavaScript: name the caller that wants a namespace binding --- rewrite-javascript/rewrite/src/javascript/binding.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/rewrite-javascript/rewrite/src/javascript/binding.ts b/rewrite-javascript/rewrite/src/javascript/binding.ts index 09ee59a203..c59d473a2a 100644 --- a/rewrite-javascript/rewrite/src/javascript/binding.ts +++ b/rewrite-javascript/rewrite/src/javascript/binding.ts @@ -152,7 +152,8 @@ interface ModuleObjectBinding { /** * `"default"` and `"namespace"` bind different values — a namespace object's default sits at * `.default` — so only one answers a whole-module request of the matching form. CommonJS has - * no such split: `"require"` answers either. + * no such split: `"require"` answers either. A caller wanting the namespace asks for + * `member: "*"`, which is what a module exporting no default is bound by. */ shape: "default" | "namespace" | "require"; } From 1ebd8ba2dcead6fc2619a681c9ddb96c5e8b08e5 Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Fri, 28 Aug 2026 04:45:06 +0200 Subject: [PATCH 30/41] JavaScript: add maybeRebind, the third binding primitive Moves a binding from one module/member to another, keeping the local name it already had - the primitive behind a member rename or a module move, so a caller never has to thread that name through a separate unbind and bind itself. The ESM lane can't be built on maybeUnbind: RemoveImport only removes a binding that's unused, but a rebind moves one that's still very much in use. existingImportBinding (add-import.ts) answers synchronously, at decide time, whether anything binds (module, member), its local name, and whether it's the only thing its import statement binds - including the CommonJS-needing-a-create refusal, before anything is queued, so there's no risk of removing the old specifier and then refusing to add the replacement mid-flight. RebindImport (deferred, mirrors AddImport's own pattern) rewrites the statement in place when it's the sole binding - porting ChangeImport's own aliasing() helper so a member rename keeps printing the old local name - or drops just that specifier and calls bindImport for the replacement, aliased to the preserved name. The AMD lane is simpler than unbind-plus-bind: withDependencyModuleAt swaps a dependency's module value at a known index, leaving the parameter and every other entry's position untouched, rather than moving the entry to the end and shifting everything after it. ChangeImport migrates onto maybeRebind as its acceptance test and loses 277 lines - aliasing, checkForOldImport, getNamedImports, removeNamedImportMember, and the whole visitImportDeclaration override - keeping its 15 tests green unchanged. Its newAlias option, an explicit local-name override with no equivalent in MaybeRebindOptions and no test coverage, is left declared but unconsulted: doing it correctly needs a scope-correct rename of every reference to the old name, which rewrite-javascript doesn't have yet. --- .../rewrite/src/javascript/add-import.ts | 220 ++++++++++++- .../rewrite/src/javascript/amd.ts | 64 ++++ .../rewrite/src/javascript/binding.ts | 46 ++- .../rewrite/src/javascript/index.ts | 4 +- .../src/javascript/recipes/change-import.ts | 290 +----------------- .../rewrite/test/javascript/binding.test.ts | 73 ++++- 6 files changed, 414 insertions(+), 283 deletions(-) diff --git a/rewrite-javascript/rewrite/src/javascript/add-import.ts b/rewrite-javascript/rewrite/src/javascript/add-import.ts index ea82494876..28936f48d1 100644 --- a/rewrite-javascript/rewrite/src/javascript/add-import.ts +++ b/rewrite-javascript/rewrite/src/javascript/add-import.ts @@ -1,11 +1,12 @@ import {JavaScriptVisitor} from "./visitor"; -import {emptySpace, J, rightPadded, singleSpace, space, Statement, Type} from "../java"; +import {emptySpace, isIdentifier, J, rightPadded, singleSpace, space, Statement, Type} from "../java"; import {JS, JSX} from "./tree"; import {randomId} from "../uuid"; import {emptyMarkers, markers} from "../markers"; import {getStyle, PrettierStyle, SpacesStyle, StyleKind} from "./style"; import {Cursor} from "../tree"; import {bindingNames, namesDeclaredIn} from "./scope"; +import {create as produce, Draft} from "mutative"; export type QuoteChar = "'" | '"'; @@ -1574,3 +1575,220 @@ export class AddImport

extends JavaScriptVisitor

{ } + +/** + * The local name `jsImport` binds `member` of `module` to, or `undefined` where it binds + * something else. `'default'` and an absent `member` both mean the default import, matching + * {@link memberName}. + */ +function importBinds(jsImport: JS.Import, module: string, member: string | undefined): string | undefined { + const specifier = jsImport.moduleSpecifier?.element; + if (specifier?.kind !== J.Kind.Literal || (specifier as J.Literal).value !== module) { + return undefined; + } + const importClause = jsImport.importClause; + if (!importClause) { + return undefined; + } + const key = memberName(member); + if (key === undefined) { + const nameElem = importClause.name?.element; + return nameElem && isIdentifier(nameElem) ? nameElem.simpleName : undefined; + } + if (key === '*') { + const namedBindings = importClause.namedBindings; + return namedBindings?.kind === JS.Kind.Alias && isIdentifier((namedBindings as JS.Alias).alias) + ? ((namedBindings as JS.Alias).alias as J.Identifier).simpleName + : undefined; + } + const namedBindings = importClause.namedBindings; + if (namedBindings?.kind !== JS.Kind.NamedImports) { + return undefined; + } + for (const elem of (namedBindings as JS.NamedImports).elements.elements) { + const specifierNode = elem.element.specifier; + if (isIdentifier(specifierNode) && specifierNode.simpleName === key) { + return specifierNode.simpleName; + } + if (specifierNode.kind === JS.Kind.Alias) { + const alias = specifierNode as JS.Alias; + const propertyName = alias.propertyName.element; + if (isIdentifier(propertyName) && propertyName.simpleName === key && isIdentifier(alias.alias)) { + return alias.alias.simpleName; + } + } + } + return undefined; +} + +/** How many named specifiers `jsImport` carries, `0` for a default, namespace or side-effect import. */ +function namedImportCount(jsImport: JS.Import): number { + const namedBindings = jsImport.importClause?.namedBindings; + return namedBindings?.kind === JS.Kind.NamedImports ? (namedBindings as JS.NamedImports).elements.elements.length : 0; +} + +/** + * Whether `member` is the only thing `jsImport` binds — a namespace import always is, a default + * import is unless it also carries named bindings, and a named import is only when it is alone + * in its `{}`. + */ +function isOnlyMember(jsImport: JS.Import, member: string | undefined): boolean { + const key = memberName(member); + return key === '*' || + (key === undefined && !jsImport.importClause?.namedBindings) || + namedImportCount(jsImport) === 1; +} + +export interface ExistingImportBinding { + localName: string; + onlyMemberOfStatement: boolean; +} + +/** + * The existing binding for `member` of `module`, read from `cu`'s own import statements — what + * `maybeRebind` reads before committing to a `RebindImport` edit. + */ +export function existingImportBinding( + cu: JS.CompilationUnit, + module: string, + member: string | undefined +): ExistingImportBinding | undefined { + for (const stmt of cu.statements) { + const element = stmt.element; + if (element?.kind !== JS.Kind.Import) { + continue; + } + const localName = importBinds(element as JS.Import, module, member); + if (localName !== undefined) { + return {localName, onlyMemberOfStatement: isOnlyMember(element as JS.Import, member)}; + } + } + return undefined; +} + +/** + * Binds `member` under the name `local` already carries, so the file's references to it still + * resolve. `local` itself becomes the alias, keeping the type attribution it holds, and the alias + * takes its prefix: that whitespace separates the specifier from a `type` keyword before it. + */ +function aliasing(local: J.Identifier, member: string): JS.Alias { + const propertyName: J.Identifier = { + id: randomId(), + kind: J.Kind.Identifier, + prefix: emptySpace, + markers: emptyMarkers, + annotations: [], + simpleName: member, + type: undefined, + fieldType: undefined + }; + return { + id: randomId(), + kind: JS.Kind.Alias, + prefix: local.prefix, + markers: emptyMarkers, + propertyName: rightPadded(propertyName, singleSpace), + alias: {...local, prefix: singleSpace} + }; +} + +/** Drops the specifier binding `member` from `jsImport`'s named list. */ +function removeNamedSpecifier(jsImport: JS.Import, member: string): JS.Import { + return produce(jsImport, draft => { + const importClause = draft.importClause; + if (importClause?.namedBindings?.kind !== JS.Kind.NamedImports) { + return; + } + const namedImports = importClause.namedBindings as Draft; + namedImports.elements.elements = namedImports.elements.elements.filter(elem => { + const specifierNode = elem.element.specifier; + if (specifierNode.kind === J.Kind.Identifier) { + return specifierNode.simpleName !== member; + } + if (specifierNode.kind === JS.Kind.Alias) { + const propertyName = (specifierNode as Draft).propertyName.element; + if (propertyName.kind === J.Kind.Identifier) { + return propertyName.simpleName !== member; + } + } + return true; + }); + }); +} + +/** + * Moves the binding `from` names to `to`, keeping the local name it already had. In place when + * the statement that carries it binds nothing else — module and member specifier rewritten there + * directly; otherwise the old specifier drops and {@link bindImport} queues the replacement, + * aliased to the preserved name. Neither branch refuses: `maybeRebind` queues this only once it + * has confirmed the move is safe. + */ +export class RebindImport

extends JavaScriptVisitor

{ + constructor( + readonly from: {module: string; member?: string}, + readonly to: {module: string; member?: string}, + readonly localName: string + ) { + super(); + } + + private transformedInPlace = false; + + override async visitJsCompilationUnit(cu: JS.CompilationUnit, p: P): Promise { + const visited = await super.visitJsCompilationUnit(cu, p) as JS.CompilationUnit; + if (!this.transformedInPlace) { + bindImport(this, { + module: this.to.module, + member: this.to.member, + alias: this.localName, + onlyIfReferenced: false + }); + } + return visited; + } + + override async visitImportDeclaration(jsImport: JS.Import, p: P): Promise { + const imp = await super.visitImportDeclaration(jsImport, p) as JS.Import; + + const key = memberName(this.from.member); + if (importBinds(imp, this.from.module, this.from.member) === undefined) { + return imp; + } + + if (!isOnlyMember(imp, this.from.member)) { + return removeNamedSpecifier(imp, key!); + } + + this.transformedInPlace = true; + return produce(imp, draft => { + const literal = draft.moduleSpecifier!.element as Draft; + literal.value = this.to.module; + const originalSource = literal.valueSource || `"${this.from.module}"`; + const quoteChar = originalSource.startsWith("'") ? "'" : '"'; + literal.valueSource = `${quoteChar}${this.to.module}${quoteChar}`; + + // A named specifier's local name has to stay put in the source, since it is what the + // rest of the file already reads; default and namespace imports carry that name on + // the clause itself, which needs no edit for a module-only move. + const toKey = memberName(this.to.member); + if (key !== undefined && key !== '*' && toKey !== undefined && toKey !== key) { + const importClause = draft.importClause; + if (importClause?.namedBindings?.kind === JS.Kind.NamedImports) { + const namedImports = importClause.namedBindings as Draft; + for (const elem of namedImports.elements.elements) { + const specifier = elem.element; + if (specifier.specifier.kind === J.Kind.Identifier && specifier.specifier.simpleName === key) { + specifier.specifier = aliasing(specifier.specifier as Draft, toKey) as Draft; + } else if (specifier.specifier.kind === JS.Kind.Alias) { + const aliasNode = specifier.specifier as Draft; + const propertyName = aliasNode.propertyName.element; + if (propertyName.kind === J.Kind.Identifier && propertyName.simpleName === key) { + propertyName.simpleName = toKey; + } + } + } + } + } + }); + } +} diff --git a/rewrite-javascript/rewrite/src/javascript/amd.ts b/rewrite-javascript/rewrite/src/javascript/amd.ts index a572f53244..c8980fa99d 100644 --- a/rewrite-javascript/rewrite/src/javascript/amd.ts +++ b/rewrite-javascript/rewrite/src/javascript/amd.ts @@ -481,6 +481,27 @@ export function withoutDependencyAt( return withParts(call, block, dependencies, factory); } +/** + * Swaps the module the dependency at `index` names, leaving its parameter and every other + * entry's position untouched — simpler than a removal and a fresh append, which would shift + * every dependency after it and re-litigate the parameter pairing this module exists to protect. + */ +export function withDependencyModuleAt( + call: J.MethodInvocation, + block: AmdBlock, + index: number, + module: string +): J.MethodInvocation { + const quote = quoteOf(block); + const elements = [...elementsOf(block)]; + const entry = elements[index]; + const literal = entry.element as J.Literal; + const updated: J.Literal = {...literal, value: module, valueSource: `${quote}${module}${quote}`}; + elements[index] = {...entry, element: updated}; + const dependencies = withElements(block.dependencies, elements); + return withParts(call, block, dependencies, block.factory); +} + function withParts( call: J.MethodInvocation, block: AmdBlock, @@ -721,6 +742,49 @@ export class AddAmdDependency

extends JavaScriptVisitor

{ } } +/** + * Applies a `maybeRebind` call's decision on the AMD lane: swaps the dependency's module in + * place, found by the block's tree id and the module it named when the caller asked. + */ +export class RebindAmdDependency

extends JavaScriptVisitor

{ + constructor( + readonly blockId: UUID, + readonly fromModule: string, + readonly toModule: string, + readonly callees: readonly string[] + ) { + super(); + } + + private applied = false; + + override async visitJsCompilationUnit(cu: JS.CompilationUnit, p: P): Promise { + const visited = await super.visitJsCompilationUnit(cu, p) as JS.CompilationUnit; + if (!this.applied) { + throw new Error(`No AMD block ${this.blockId} to rebind '${this.fromModule}' to '${this.toModule}' on`); + } + return visited; + } + + override async visitMethodInvocation(m: J.MethodInvocation, p: P): Promise { + const visited = await super.visitMethodInvocation(m, p) as J.MethodInvocation; + if (visited.id !== this.blockId) { + return visited; + } + const block = amdBlockOf(visited, this.callees); + if (block === undefined) { + return visited; + } + this.applied = true; + const index = dependencyNames(block).indexOf(this.fromModule); + if (index < 0) { + throw new Error( + `AMD block ${this.blockId} no longer has dependency '${this.fromModule}' to rebind to '${this.toModule}'`); + } + return withDependencyModuleAt(visited, block, index, this.toModule); + } +} + /** * The AMD counterpart to `RemoveImport`: drops a dependency the block's body no longer names, * along with the parameter bound to it. A dependency the factory takes no parameter for is diff --git a/rewrite-javascript/rewrite/src/javascript/binding.ts b/rewrite-javascript/rewrite/src/javascript/binding.ts index c59d473a2a..3af44685a5 100644 --- a/rewrite-javascript/rewrite/src/javascript/binding.ts +++ b/rewrite-javascript/rewrite/src/javascript/binding.ts @@ -18,11 +18,11 @@ import {JS} from "./tree"; import {Cursor} from "../tree"; import {JavaScriptVisitor} from "./visitor"; import {scopeOf, walk} from "./scope"; -import {AddImportOptions, bindImport, moduleNameOf} from "./add-import"; +import {AddImportOptions, bindImport, existingImportBinding, moduleNameOf, RebindImport} from "./add-import"; import {RemoveImport} from "./remove-import"; import { AmdCalleeOptions, amdBlockOf, bindAmd, calleesOf, dependencyNames, enclosingAmdBlock, lastSegment, - parameterNames, RemoveAmdDependency + parameterNames, RebindAmdDependency, RemoveAmdDependency } from "./amd"; export interface MaybeBindOptions extends AddImportOptions { @@ -40,6 +40,14 @@ export interface MaybeUnbindOptions { amdCallee?: string | readonly string[]; } +export interface MaybeRebindOptions { + from: {module: string; member?: string}; + to: {module: string; member?: string}; + + /** Callees that introduce an AMD block. UI5 writes `sap.ui.define`, RequireJS and Dojo `define`. */ + amdCallee?: string | readonly string[]; +} + export interface ModuleBindings { /** * The module `localName` refers to, or undefined when it is not a module binding — of any @@ -349,6 +357,40 @@ export function maybeUnbind(visitor: JavaScriptVisitor, options: MaybeUnbin visitor.afterVisit.push(new RemoveAmdDependency(options.module, options.member, calleesOf(options))); } +/** + * Moves the binding for `from` to `to`, keeping the local name it already had — the primitive + * behind a member rename or a module move, so a caller never has to thread that name through a + * separate unbind and bind itself. Refuses, changing nothing, where nothing binds `from`, where + * `from` or `to` names a member on the AMD lane (a factory parameter binds only a whole module), + * or where completing the move on the ESM lane would have to gain an import into a CommonJS file. + */ +export function maybeRebind(visitor: JavaScriptVisitor, options: MaybeRebindOptions): string | undefined { + const amd = enclosingAmdBlock(visitor, options); + if (amd !== undefined) { + if (options.from.member !== undefined || options.to.member !== undefined) { + return undefined; + } + const index = dependencyNames(amd.block).indexOf(options.from.module); + const binding = index < 0 ? undefined : parameterNames(amd.block)[index]; + if (binding === undefined) { + return undefined; + } + visitor.afterVisit.push(new RebindAmdDependency(amd.call.id, options.from.module, options.to.module, calleesOf(options))); + return binding; + } + + const cu = compilationUnitOf(visitor); + const existing = cu && existingImportBinding(cu, options.from.module, options.from.member); + if (existing === undefined) { + return undefined; + } + if (!existing.onlyMemberOfStatement && moduleBindings(visitor, options).moduleSystem === "commonjs") { + return undefined; + } + visitor.afterVisit.push(new RebindImport(options.from, options.to, existing.localName)); + return existing.localName; +} + /** * @deprecated Use {@link maybeUnbind} instead — this is a call-shape change, not a behaviour * change: `maybeRemoveImport(v, module, member)` is `maybeUnbind(v, {module, member})`. diff --git a/rewrite-javascript/rewrite/src/javascript/index.ts b/rewrite-javascript/rewrite/src/javascript/index.ts index 8384f1da71..b0dbd2b824 100644 --- a/rewrite-javascript/rewrite/src/javascript/index.ts +++ b/rewrite-javascript/rewrite/src/javascript/index.ts @@ -41,8 +41,8 @@ export { DEFAULT_AMD_CALLEES, amdBlockOf, dependencyNames, parameterNames, withDependency, withoutDependencyAt, RemoveAmdDependency, removeNewlyUnusedAmdBindings } from "./amd"; -export type {MaybeBindOptions, MaybeUnbindOptions, ModuleBindings} from "./binding"; -export {maybeBind, maybeUnbind, moduleBindings, isAmdBlock, maybeRemoveImport, maybeAddImport} from "./binding"; +export type {MaybeBindOptions, MaybeUnbindOptions, MaybeRebindOptions, ModuleBindings} from "./binding"; +export {maybeBind, maybeUnbind, maybeRebind, moduleBindings, isAmdBlock, maybeRemoveImport, maybeAddImport} from "./binding"; export * from "./remove-import"; export * from "./cleanup/index"; export * from "./recipes/index"; diff --git a/rewrite-javascript/rewrite/src/javascript/recipes/change-import.ts b/rewrite-javascript/rewrite/src/javascript/recipes/change-import.ts index 4a3e81ad09..b56323dd68 100644 --- a/rewrite-javascript/rewrite/src/javascript/recipes/change-import.ts +++ b/rewrite-javascript/rewrite/src/javascript/recipes/change-import.ts @@ -18,37 +18,9 @@ import { Option, Recipe } from "../../recipe"; import { TreeVisitor } from "../../visitor"; import { ExecutionContext } from "../../execution"; import { JavaScriptVisitor, JS } from "../index"; -import { maybeBind } from "../binding"; -import { emptySpace, J, isIdentifier, rightPadded, singleSpace, Type } from "../../java"; -import { create as produce, Draft } from "mutative"; -import { randomId } from "../../uuid"; -import { emptyMarkers } from "../../markers"; - -/** - * Binds `member` under the name `local` already carries, so the file's references to it still - * resolve. `local` itself becomes the alias, keeping the type attribution it holds, and the alias - * takes its prefix: that whitespace separates the specifier from a `type` keyword before it. - */ -function aliasing(local: Draft, member: string): JS.Alias { - const propertyName: J.Identifier = { - id: randomId(), - kind: J.Kind.Identifier, - prefix: emptySpace, - markers: emptyMarkers, - annotations: [], - simpleName: member, - type: undefined, - fieldType: undefined - }; - return { - id: randomId(), - kind: JS.Kind.Alias, - prefix: local.prefix, - markers: emptyMarkers, - propertyName: rightPadded(propertyName, singleSpace), - alias: {...local, prefix: singleSpace} as J.Identifier - }; -} +import { maybeRebind } from "../binding"; +import { J, Type } from "../../java"; +import { create as produce } from "mutative"; /** * Changes an import from one module to another, updating all type attributions. @@ -113,6 +85,11 @@ export class ChangeImport extends Recipe { }) newMember?: string; + /** + * Currently has no effect: the move keeps whatever local name the binding already had, and + * overriding that to a different name would leave the file's existing references to it + * unbound without a scope-correct rename this module does not yet have. + */ @Option({ displayName: "New alias", description: "Optional alias for the new import. Required when newMember is 'default' or '*'.", @@ -135,7 +112,6 @@ export class ChangeImport extends Recipe { const oldMember = this.oldMember; const newModule = this.newModule; const newMember = this.newMember ?? oldMember; - const newAlias = this.newAlias; // Build the old and new FQNs for type attribution updates const oldFqn = oldMember === 'default' || oldMember === '*' @@ -147,181 +123,14 @@ export class ChangeImport extends Recipe { return new class extends JavaScriptVisitor { private hasOldImport = false; - private oldAlias?: string; - private transformedImport = false; override async visitJsCompilationUnit(cu: JS.CompilationUnit, ctx: ExecutionContext): Promise { - // Reset tracking for each file - this.hasOldImport = false; - this.oldAlias = undefined; - this.transformedImport = false; - - // First pass: check if the old import exists and capture any alias - for (const statement of cu.statements) { - const stmt = statement.element ?? statement; - if (stmt.kind === JS.Kind.Import) { - const jsImport = stmt as JS.Import; - const aliasInfo = this.checkForOldImport(jsImport); - if (aliasInfo.found) { - this.hasOldImport = true; - this.oldAlias = aliasInfo.alias; - break; - } - } - } - - // Visit the compilation unit (this will transform imports via visitJsImport) - let result = await super.visitJsCompilationUnit(cu, ctx) as JS.CompilationUnit; - - // If we transformed an import but need to add to existing import from new module, - // or if we only removed a member from a multi-import, use maybeBind - if (this.hasOldImport && !this.transformedImport) { - const aliasToUse = newAlias ?? this.oldAlias; - - if (newMember === 'default') { - maybeBind(this, { - module: newModule, - member: 'default', - alias: aliasToUse, - onlyIfReferenced: false - }); - } else if (newMember === '*') { - maybeBind(this, { - module: newModule, - member: '*', - alias: aliasToUse, - onlyIfReferenced: false - }); - } else { - maybeBind(this, { - module: newModule, - member: newMember, - // A pinned alias is taken verbatim: `oldMember` is the name this - // import bound, which the file's references to it already read. - alias: aliasToUse ?? oldMember, - onlyIfReferenced: false - }); - } - } - - return result; - } - - override async visitImportDeclaration(jsImport: JS.Import, ctx: ExecutionContext): Promise { - let imp = await super.visitImportDeclaration(jsImport, ctx) as JS.Import; - - if (!this.hasOldImport) { - return imp; - } - - const aliasInfo = this.checkForOldImport(imp); - if (!aliasInfo.found) { - return imp; - } - - // Check if this is the only import from the old module - const namedImports = this.getNamedImports(imp); - const isOnlyImport = namedImports.length === 1 || - (oldMember === 'default' && !imp.importClause?.namedBindings) || - (oldMember === '*'); - - if (isOnlyImport) { - // Transform the module specifier in place - this.transformedImport = true; - return produce(imp, draft => { - if (draft.moduleSpecifier) { - const literal = draft.moduleSpecifier.element as Draft; - literal.value = newModule; - // Update valueSource to preserve quote style - const originalSource = literal.valueSource || `"${oldModule}"`; - const quoteChar = originalSource.startsWith("'") ? "'" : '"'; - literal.valueSource = `${quoteChar}${newModule}${quoteChar}`; - } - // If we're also renaming the member, update the import specifier - if (newMember !== oldMember && oldMember !== 'default' && oldMember !== '*') { - const importClause = draft.importClause; - if (importClause?.namedBindings?.kind === JS.Kind.NamedImports) { - const namedImports = importClause.namedBindings as Draft; - for (const elem of namedImports.elements.elements) { - const specifier = elem.element; - if (specifier.specifier.kind === J.Kind.Identifier && - specifier.specifier.simpleName === oldMember) { - specifier.specifier = aliasing(specifier.specifier as Draft, newMember); - } else if (specifier.specifier.kind === JS.Kind.Alias) { - const aliasNode = specifier.specifier as Draft; - const propertyName = aliasNode.propertyName.element; - if (propertyName.kind === J.Kind.Identifier && - propertyName.simpleName === oldMember) { - propertyName.simpleName = newMember; - } - } - } - } - } - }); - } else { - // Remove just the specific member from the import - // maybeBind will add the new import - return this.removeNamedImportMember(imp, oldMember, ctx); - } - } - - private async removeNamedImportMember(imp: JS.Import, memberToRemove: string, _ctx: ExecutionContext): Promise { - return produce(imp, draft => { - const importClause = draft.importClause; - if (!importClause?.namedBindings) return; - if (importClause.namedBindings.kind !== JS.Kind.NamedImports) return; - - const namedImports = importClause.namedBindings as Draft; - const elements = namedImports.elements.elements; - const filteredElements = elements.filter(elem => { - const specifier = elem.element; - const specifierNode = specifier.specifier; - - if (specifierNode.kind === J.Kind.Identifier) { - return specifierNode.simpleName !== memberToRemove; - } - - if (specifierNode.kind === JS.Kind.Alias) { - const alias = specifierNode as JS.Alias; - const propertyName = alias.propertyName.element; - if (propertyName.kind === J.Kind.Identifier) { - return propertyName.simpleName !== memberToRemove; - } - } - - return true; - }); + this.hasOldImport = maybeRebind(this, { + from: {module: oldModule, member: oldMember}, + to: {module: newModule, member: newMember} + }) !== undefined; - namedImports.elements.elements = filteredElements; - }); - } - - private getNamedImports(imp: JS.Import): string[] { - const imports: string[] = []; - const importClause = imp.importClause; - if (!importClause) return imports; - - const namedBindings = importClause.namedBindings; - if (!namedBindings || namedBindings.kind !== JS.Kind.NamedImports) return imports; - - const namedImports = namedBindings as JS.NamedImports; - for (const elem of namedImports.elements.elements) { - const specifier = elem.element; - const specifierNode = specifier.specifier; - - if (isIdentifier(specifierNode)) { - imports.push(specifierNode.simpleName); - } else if (specifierNode.kind === JS.Kind.Alias) { - const alias = specifierNode as JS.Alias; - const propertyName = alias.propertyName.element; - if (isIdentifier(propertyName)) { - imports.push(propertyName.simpleName); - } - } - } - - return imports; + return super.visitJsCompilationUnit(cu, ctx); } override async visitIdentifier(identifier: J.Identifier, ctx: ExecutionContext): Promise { @@ -653,79 +462,6 @@ export class ChangeImport extends Recipe { } return arrayType; } - - private checkForOldImport(jsImport: JS.Import): { found: boolean; alias?: string } { - // Check if this import is from the old module - const moduleSpecifier = jsImport.moduleSpecifier; - if (!moduleSpecifier) return { found: false }; - - const literal = moduleSpecifier.element; - if (literal.kind !== J.Kind.Literal) return { found: false }; - - const value = (literal as J.Literal).value; - if (value !== oldModule) return { found: false }; - - const importClause = jsImport.importClause; - if (!importClause) { - // Side-effect import - not what we're looking for - return { found: false }; - } - - // Check for default import - if (oldMember === 'default') { - if (importClause.name) { - const nameElem = importClause.name.element; - if (isIdentifier(nameElem)) { - return { found: true, alias: nameElem.simpleName }; - } - } - return { found: false }; - } - - // Check for namespace import - if (oldMember === '*') { - const namedBindings = importClause.namedBindings; - if (namedBindings?.kind === JS.Kind.Alias) { - const alias = namedBindings as JS.Alias; - if (isIdentifier(alias.alias)) { - return { found: true, alias: alias.alias.simpleName }; - } - } - return { found: false }; - } - - // Check for named imports - const namedBindings = importClause.namedBindings; - if (!namedBindings) return { found: false }; - - if (namedBindings.kind !== JS.Kind.NamedImports) return { found: false }; - - const namedImports = namedBindings as JS.NamedImports; - const elements = namedImports.elements.elements; - - for (const elem of elements) { - const specifier = elem.element; - const specifierNode = specifier.specifier; - - // Handle direct import: import { act } - if (isIdentifier(specifierNode) && specifierNode.simpleName === oldMember) { - return { found: true }; - } - - // Handle aliased import: import { act as something } - if (specifierNode.kind === JS.Kind.Alias) { - const alias = specifierNode as JS.Alias; - const propertyName = alias.propertyName.element; - if (isIdentifier(propertyName) && propertyName.simpleName === oldMember) { - if (isIdentifier(alias.alias)) { - return { found: true, alias: alias.alias.simpleName }; - } - } - } - } - - return { found: false }; - } }(); } } diff --git a/rewrite-javascript/rewrite/test/javascript/binding.test.ts b/rewrite-javascript/rewrite/test/javascript/binding.test.ts index 5397834399..ec48ade20b 100644 --- a/rewrite-javascript/rewrite/test/javascript/binding.test.ts +++ b/rewrite-javascript/rewrite/test/javascript/binding.test.ts @@ -1,7 +1,7 @@ import {fromVisitor, RecipeSpec} from "../../src/test"; import { JavaScriptVisitor, JS, javascript, typescript, moduleBindings, isAmdBlock, ModuleBindings, maybeBind, - maybeAddImport, maybeUnbind, maybeRemoveImport, removeNewlyUnusedAmdBindings + maybeAddImport, maybeUnbind, maybeRebind, maybeRemoveImport, removeNewlyUnusedAmdBindings } from "../../src/javascript"; import {emptySpace, J, rightPadded} from "../../src/java"; import {emptyMarkers} from "../../src/markers"; @@ -628,6 +628,77 @@ describe("maybeBind", () => { }); }); +describe("maybeRebind", () => { + test("an ESM member move keeps the local name", async () => { + const spec = new RecipeSpec(); + const bound: {name?: string} = {}; + spec.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitJsCompilationUnit(cu: JS.CompilationUnit, p: any): Promise { + bound.name = maybeRebind(this, { + from: {module: "lodash", member: "extend"}, + to: {module: "lodash", member: "assign"} + }); + return super.visitJsCompilationUnit(cu, p); + } + }); + await spec.rewriteRun(typescript( + `import { extend } from "lodash";\n\nextend({}, {});`, + `import { assign as extend } from "lodash";\n\nextend({}, {});` + )); + expect(bound.name).toBe("extend"); + }); + + test("an AMD dependency swap keeps the parameter and its index", async () => { + const spec = new RecipeSpec(); + const bound: {name?: string} = {}; + spec.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitMethodInvocation(m: J.MethodInvocation, p: any): Promise { + if (m.name.simpleName !== "target") { + return super.visitMethodInvocation(m, p); + } + bound.name = maybeRebind(this, {from: {module: "a/Old"}, to: {module: "a/New"}}); + return m; + } + }); + await spec.rewriteRun(javascript( + `sap.ui.define(["a/B", "a/Old", "a/C"], function (B, Old, C) { target(); });`, + `sap.ui.define(["a/B", "a/New", "a/C"], function (B, Old, C) { target(); });` + )); + expect(bound.name).toBe("Old"); + }); + + test("a rebind of a module nothing binds returns undefined and changes nothing", async () => { + const spec = new RecipeSpec(); + const bound: {name?: string} = {}; + spec.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitJsCompilationUnit(cu: JS.CompilationUnit, p: any): Promise { + bound.name = maybeRebind(this, {from: {module: "nope"}, to: {module: "also-nope"}}); + return super.visitJsCompilationUnit(cu, p); + } + }); + await spec.rewriteRun(typescript(`const x = 1;`)); + expect(bound.name).toBeUndefined(); + }); + + test("a member rebind refuses on AMD, since a factory parameter cannot bind one", async () => { + const spec = new RecipeSpec(); + const bound: {name?: string} = {}; + spec.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitMethodInvocation(m: J.MethodInvocation, p: any): Promise { + if (m.name.simpleName !== "target") { + return super.visitMethodInvocation(m, p); + } + bound.name = maybeRebind(this, {from: {module: "a/Old", member: "x"}, to: {module: "a/New"}}); + return m; + } + }); + await spec.rewriteRun(javascript( + `sap.ui.define(["a/Old"], function (Old) { target(); });` + )); + expect(bound.name).toBeUndefined(); + }); +}); + function dropModule(module: string, member?: string) { return new class extends JavaScriptVisitor { override async visitJsCompilationUnit(cu: JS.CompilationUnit, p: any): Promise { From 13791e2d90acb06bc3e87d059ce2d34989d58ade Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Fri, 28 Aug 2026 04:50:52 +0200 Subject: [PATCH 31/41] JavaScript: accept a bare module string on maybeBind and maybeUnbind maybeRemoveImport(v, "fs") takes positional arguments today; maybeUnbind(v, {module: "fs"}) was strictly more typing for the same call, which makes migrating off the deprecated function feel punitive rather than a rename. Same on the bind side - downstream UI5 recipes were written against a positional bindModule(this, module), and every call got longer in the unification. maybeBind and maybeUnbind now accept a bare string as shorthand for {module}, normalized to the options object in one line at the top of each function so nothing downstream sees the union. maybeRebind does not get one: from/to make a single-value shorthand ambiguous by construction, so the asymmetry is the shape telling the truth rather than a gap. --- .../rewrite/src/javascript/binding.ts | 12 +++++-- .../rewrite/test/javascript/binding.test.ts | 33 +++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/rewrite-javascript/rewrite/src/javascript/binding.ts b/rewrite-javascript/rewrite/src/javascript/binding.ts index 3af44685a5..b0de3f9d17 100644 --- a/rewrite-javascript/rewrite/src/javascript/binding.ts +++ b/rewrite-javascript/rewrite/src/javascript/binding.ts @@ -25,11 +25,13 @@ import { parameterNames, RebindAmdDependency, RemoveAmdDependency } from "./amd"; +/** A bare string is shorthand for `{module}`, the overwhelmingly common call with nothing else to configure. */ export interface MaybeBindOptions extends AddImportOptions { /** Callees that introduce an AMD block. UI5 writes `sap.ui.define`, RequireJS and Dojo `define`. */ amdCallee?: string | readonly string[]; } +/** A bare string is shorthand for `{module}`, the overwhelmingly common call with nothing else to configure. */ export interface MaybeUnbindOptions { module: string; @@ -306,8 +308,11 @@ function answersWholeModuleRequest(binding: ModuleObjectBinding, wantsNamespace: */ export function maybeBind( visitor: JavaScriptVisitor, - options: MaybeBindOptions + options: MaybeBindOptions | string ): string | undefined { + if (typeof options === "string") { + options = {module: options}; + } const module = moduleNameOf(options.module); const amd = enclosingAmdBlock(visitor, options); @@ -344,7 +349,10 @@ export function maybeBind( * the default and namespace import regardless of local name. A member-scoped request does not * apply to an AMD dependency, which binds a module rather than one of its members. */ -export function maybeUnbind(visitor: JavaScriptVisitor, options: MaybeUnbindOptions): void { +export function maybeUnbind(visitor: JavaScriptVisitor, options: MaybeUnbindOptions | string): void { + if (typeof options === "string") { + options = {module: options}; + } for (const v of visitor.afterVisit || []) { if (v instanceof RemoveImport && v.module === options.module && v.member === options.member) { return; diff --git a/rewrite-javascript/rewrite/test/javascript/binding.test.ts b/rewrite-javascript/rewrite/test/javascript/binding.test.ts index ec48ade20b..f1fc70c389 100644 --- a/rewrite-javascript/rewrite/test/javascript/binding.test.ts +++ b/rewrite-javascript/rewrite/test/javascript/binding.test.ts @@ -437,6 +437,25 @@ describe("maybeBind", () => { expect(bound.name).toBe("Element"); }); + test("a bare string is shorthand for {module}", async () => { + const spec = new RecipeSpec(); + const bound: {name?: string} = {}; + spec.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitMethodInvocation(m: J.MethodInvocation, p: any): Promise { + if (m.name.simpleName !== "target") { + return super.visitMethodInvocation(m, p); + } + bound.name = maybeBind(this, "sap/ui/core/Element"); + return bound.name === undefined ? m : withReference(m, bound.name); + } + }); + await spec.rewriteRun(typescript( + `target();`, + `import Element from 'sap/ui/core/Element';\n\nElement.target();` + )); + expect(bound.name).toBe("Element"); + }); + test("ESM reuses a default import bound under another name", async () => { const spec = new RecipeSpec(); const bound: {name?: string} = {}; @@ -767,6 +786,20 @@ describe("maybeUnbind on an ESM file", () => { )); }); + test("a bare string is shorthand for {module}", async () => { + const spec = new RecipeSpec(); + spec.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitJsCompilationUnit(cu: JS.CompilationUnit, p: any): Promise { + maybeUnbind(this, "sap/m/Button"); + return super.visitJsCompilationUnit(cu, p); + } + }); + await spec.rewriteRun(typescript( + `import Button from "sap/m/Button";\n\nconsole.log(1);`, + `console.log(1);` + )); + }); + test("the deprecated maybeRemoveImport still works, doing what the equivalent maybeUnbind call would", async () => { const removeWith = (remove: (visitor: JavaScriptVisitor) => void) => { const spec = new RecipeSpec(); From 65b26e366f00e3eb2e0eadfa1d6e49327883cdb0 Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Fri, 28 Aug 2026 04:54:56 +0200 Subject: [PATCH 32/41] JavaScript: remove ChangeImport's newAlias option; document RemoveImport near-miss Removes ChangeImport's newAlias option (the @Option field and its constructor parameter) - a user-facing recipe option, not an internal detail. It let a caller override the local name a moved binding keeps, but it was already half-broken before this: the old code only read it on the remove-and-add path, silently ignoring it on the in-place-transform path, and neither path exists anymore now that ChangeImport is built on maybeRebind, which has no equivalent in MaybeRebindOptions. A declared option that does nothing is worse than an absent one, since the type system and recipe catalogue keep advertising it. Doing it correctly needs a scope-correct rename of every reference to the old local name, which rewrite-javascript doesn't have yet. Checked the whole repo for anything else setting newAlias - YAML recipe definitions, composite recipes, docs, tests - only change-import.ts itself ever referenced it, so nothing else needed updating. Also documents, directly in RebindImport's own doc comment, why the rebind primitive isn't built on RemoveImport/maybeUnbind: RemoveImport only ever drops a binding once nothing references it, but a rebind moves one that's still very much in use, so removal there has to be unconditional. This is durable now rather than living only in a handoff message, so the next person doesn't try the same thing. --- .../rewrite/src/javascript/add-import.ts | 6 ++++-- .../rewrite/src/javascript/recipes/change-import.ts | 13 ------------- 2 files changed, 4 insertions(+), 15 deletions(-) diff --git a/rewrite-javascript/rewrite/src/javascript/add-import.ts b/rewrite-javascript/rewrite/src/javascript/add-import.ts index 28936f48d1..aa2d695907 100644 --- a/rewrite-javascript/rewrite/src/javascript/add-import.ts +++ b/rewrite-javascript/rewrite/src/javascript/add-import.ts @@ -1720,8 +1720,10 @@ function removeNamedSpecifier(jsImport: JS.Import, member: string): JS.Import { * Moves the binding `from` names to `to`, keeping the local name it already had. In place when * the statement that carries it binds nothing else — module and member specifier rewritten there * directly; otherwise the old specifier drops and {@link bindImport} queues the replacement, - * aliased to the preserved name. Neither branch refuses: `maybeRebind` queues this only once it - * has confirmed the move is safe. + * aliased to the preserved name. + * + * Not built on `RemoveImport`/`maybeUnbind`: those only drop a binding once nothing references + * it, but a rebind moves one that is still in use — removal here has to be unconditional. */ export class RebindImport

extends JavaScriptVisitor

{ constructor( diff --git a/rewrite-javascript/rewrite/src/javascript/recipes/change-import.ts b/rewrite-javascript/rewrite/src/javascript/recipes/change-import.ts index b56323dd68..2b5e05094e 100644 --- a/rewrite-javascript/rewrite/src/javascript/recipes/change-import.ts +++ b/rewrite-javascript/rewrite/src/javascript/recipes/change-import.ts @@ -85,24 +85,11 @@ export class ChangeImport extends Recipe { }) newMember?: string; - /** - * Currently has no effect: the move keeps whatever local name the binding already had, and - * overriding that to a different name would leave the file's existing references to it - * unbound without a scope-correct rename this module does not yet have. - */ - @Option({ - displayName: "New alias", - description: "Optional alias for the new import. Required when newMember is 'default' or '*'.", - required: false - }) - newAlias?: string; - constructor(options?: { oldModule?: string; oldMember?: string; newModule?: string; newMember?: string; - newAlias?: string; }) { super(options); } From eba5da4ab5e6cfbef2ce0c97d6b3a8ded3aa6764 Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Fri, 28 Aug 2026 05:47:01 +0200 Subject: [PATCH 33/41] JavaScript: fix ten verified defects in maybeBind/maybeRebind found by deep review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Most were in maybeRebind/RebindImport, the newest code and the part that had gotten one review round instead of the three to five everything else got. Two produced wrong output that still parsed (double space, dropped type-only, lost sibling specifiers) and one refused legal input; the rest were missed cases in module-system detection and dedup keys. Mutation-verified the three findings review flagged as riskiest (AMD member normalization, the default+named whitespace fix, multi-declarator require detection): reverting each in isolation reproduces the exact wrong output the review described. Also fixes maybeRebind's own AMD refusal, which had the same raw-options.member-instead-of-normalized-key bug as finding #1 — caught while fixing that finding, not separately reported. Performance and duplication findings from the same review are out of scope for this round. --- .../rewrite/src/javascript/add-import.ts | 114 ++++++++++++------ .../rewrite/src/javascript/amd.ts | 15 ++- .../rewrite/src/javascript/binding.ts | 95 +++++++++------ .../rewrite/test/javascript/binding.test.ts | 105 ++++++++++++++++ 4 files changed, 259 insertions(+), 70 deletions(-) diff --git a/rewrite-javascript/rewrite/src/javascript/add-import.ts b/rewrite-javascript/rewrite/src/javascript/add-import.ts index aa2d695907..4ecc1554f2 100644 --- a/rewrite-javascript/rewrite/src/javascript/add-import.ts +++ b/rewrite-javascript/rewrite/src/javascript/add-import.ts @@ -1,5 +1,5 @@ import {JavaScriptVisitor} from "./visitor"; -import {emptySpace, isIdentifier, J, rightPadded, singleSpace, space, Statement, Type} from "../java"; +import {ElementRemovalFormatter, emptySpace, isIdentifier, J, rightPadded, singleSpace, space, Statement, Type} from "../java"; import {JS, JSX} from "./tree"; import {randomId} from "../uuid"; import {emptyMarkers, markers} from "../markers"; @@ -199,7 +199,7 @@ function derivedName(options: AddImportOptions): string { } /** `'default'` and an absent member both name a default import, which binds no member name of its own. */ -function memberName(member: string | undefined): string | undefined { +export function memberName(member: string | undefined): string | undefined { return member === 'default' ? undefined : member; } @@ -1628,15 +1628,18 @@ function namedImportCount(jsImport: JS.Import): number { } /** - * Whether `member` is the only thing `jsImport` binds — a namespace import always is, a default - * import is unless it also carries named bindings, and a named import is only when it is alone - * in its `{}`. + * Whether `jsImport`'s clause binds exactly one thing — a default, a namespace, or one named + * specifier. That is the only shape a module-only move can rewrite in place: whichever binding + * `importBinds` already matched is this one, so nothing else the clause carries goes along with it. */ -function isOnlyMember(jsImport: JS.Import, member: string | undefined): boolean { - const key = memberName(member); - return key === '*' || - (key === undefined && !jsImport.importClause?.namedBindings) || - namedImportCount(jsImport) === 1; +function isOnlyMember(jsImport: JS.Import): boolean { + const importClause = jsImport.importClause; + if (!importClause) { + return false; + } + const hasDefault = importClause.name !== undefined; + const hasNamespace = importClause.namedBindings?.kind === JS.Kind.Alias; + return (hasDefault ? 1 : 0) + (hasNamespace ? 1 : 0) + namedImportCount(jsImport) === 1; } export interface ExistingImportBinding { @@ -1660,7 +1663,7 @@ export function existingImportBinding( } const localName = importBinds(element as JS.Import, module, member); if (localName !== undefined) { - return {localName, onlyMemberOfStatement: isOnlyMember(element as JS.Import, member)}; + return {localName, onlyMemberOfStatement: isOnlyMember(element as JS.Import)}; } } return undefined; @@ -1692,28 +1695,66 @@ function aliasing(local: J.Identifier, member: string): JS.Alias { }; } -/** Drops the specifier binding `member` from `jsImport`'s named list. */ -function removeNamedSpecifier(jsImport: JS.Import, member: string): JS.Import { - return produce(jsImport, draft => { - const importClause = draft.importClause; - if (importClause?.namedBindings?.kind !== JS.Kind.NamedImports) { - return; - } - const namedImports = importClause.namedBindings as Draft; - namedImports.elements.elements = namedImports.elements.elements.filter(elem => { - const specifierNode = elem.element.specifier; - if (specifierNode.kind === J.Kind.Identifier) { - return specifierNode.simpleName !== member; - } - if (specifierNode.kind === JS.Kind.Alias) { - const propertyName = (specifierNode as Draft).propertyName.element; - if (propertyName.kind === J.Kind.Identifier) { - return propertyName.simpleName !== member; - } - } - return true; - }); - }); +/** + * Drops the binding for `member` from `jsImport`'s clause — the default, the namespace alias, or + * one entry of the named list, whichever `member` names — keeping everything else the clause + * binds. `ElementRemovalFormatter` carries the dropped binding's prefix onto whatever prints + * next, the same way `RemoveImport` keeps formatting sane when trimming a list. + */ +function removeBinding(jsImport: JS.Import, member: string | undefined): JS.Import { + const importClause = jsImport.importClause; + if (!importClause) { + return jsImport; + } + const key = memberName(member); + + if (key === undefined) { + if (!importClause.name) { + return jsImport; + } + const namedBindings = importClause.namedBindings; + if (namedBindings?.kind === JS.Kind.NamedImports) { + // `NamedImports` keeps the space before its own `{` on the container's `before`, + // not on its own prefix, so the removed default's prefix has to land there instead. + const namedImports = namedBindings as JS.NamedImports; + const updated: JS.NamedImports = { + ...namedImports, + elements: {...namedImports.elements, before: importClause.name.element.prefix} + }; + return {...jsImport, importClause: {...importClause, name: undefined, namedBindings: updated}}; + } + if (namedBindings) { + const formatter = new ElementRemovalFormatter(); + formatter.markRemoved(importClause.name.element); + return {...jsImport, importClause: {...importClause, name: undefined, namedBindings: formatter.processKept(namedBindings)}}; + } + return {...jsImport, importClause: {...importClause, name: undefined}}; + } + + if (key === '*') { + return {...jsImport, importClause: {...importClause, namedBindings: undefined}}; + } + + if (importClause.namedBindings?.kind !== JS.Kind.NamedImports) { + return jsImport; + } + const namedImports = importClause.namedBindings as JS.NamedImports; + const formatter = new ElementRemovalFormatter(); + const kept: J.RightPadded[] = []; + for (const entry of namedImports.elements.elements) { + const specifierNode = entry.element.specifier; + const matches = (specifierNode.kind === J.Kind.Identifier && specifierNode.simpleName === key) || + (specifierNode.kind === JS.Kind.Alias && + (specifierNode as JS.Alias).propertyName.element.kind === J.Kind.Identifier && + ((specifierNode as JS.Alias).propertyName.element as J.Identifier).simpleName === key); + if (matches) { + formatter.markRemoved(entry.element); + } else { + kept.push({...entry, element: formatter.processKept(entry.element)}); + } + } + const updatedNamedImports: JS.NamedImports = {...namedImports, elements: {...namedImports.elements, elements: kept}}; + return {...jsImport, importClause: {...importClause, namedBindings: updatedNamedImports}}; } /** @@ -1735,6 +1776,7 @@ export class RebindImport

extends JavaScriptVisitor

{ } private transformedInPlace = false; + private typeOnly = false; override async visitJsCompilationUnit(cu: JS.CompilationUnit, p: P): Promise { const visited = await super.visitJsCompilationUnit(cu, p) as JS.CompilationUnit; @@ -1743,6 +1785,7 @@ export class RebindImport

extends JavaScriptVisitor

{ module: this.to.module, member: this.to.member, alias: this.localName, + typeOnly: this.typeOnly, onlyIfReferenced: false }); } @@ -1756,9 +1799,10 @@ export class RebindImport

extends JavaScriptVisitor

{ if (importBinds(imp, this.from.module, this.from.member) === undefined) { return imp; } + this.typeOnly = imp.importClause?.typeOnly ?? false; - if (!isOnlyMember(imp, this.from.member)) { - return removeNamedSpecifier(imp, key!); + if (!isOnlyMember(imp)) { + return removeBinding(imp, this.from.member); } this.transformedInPlace = true; diff --git a/rewrite-javascript/rewrite/src/javascript/amd.ts b/rewrite-javascript/rewrite/src/javascript/amd.ts index c8980fa99d..fd212549bd 100644 --- a/rewrite-javascript/rewrite/src/javascript/amd.ts +++ b/rewrite-javascript/rewrite/src/javascript/amd.ts @@ -577,6 +577,16 @@ export function lastSegment(module: string): string { return module.substring(module.lastIndexOf("/") + 1); } +/** + * `lastSegment(module)`, or `undefined` where that string cannot bind a name — a scoped + * package's `@scope/my-lib`, a subpath's `lodash.merge`, or a bare `-`/`.`-bearing package name + * are all real module strings that are not legal identifiers. + */ +export function derivedBindingName(module: string): string | undefined { + const segment = lastSegment(module); + return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(segment) ? segment : undefined; +} + function deconflict(preferred: string, taken: readonly (string | undefined)[]): string { if (!taken.includes(preferred)) { return preferred; @@ -631,8 +641,11 @@ export function bindAmd( return undefined; } + if (preferredName === undefined && derivedBindingName(module) === undefined) { + return undefined; + } const taken = [...bindings, ...namesInScope, ...queued.map(v => v.binding)]; - const binding = deconflict(preferredName ?? lastSegment(module), taken); + const binding = deconflict(preferredName ?? derivedBindingName(module)!, taken); visitor.afterVisit.push(new AddAmdDependency(amd.call.id, module, binding, callees)); return binding; } diff --git a/rewrite-javascript/rewrite/src/javascript/binding.ts b/rewrite-javascript/rewrite/src/javascript/binding.ts index b0de3f9d17..dbac388b41 100644 --- a/rewrite-javascript/rewrite/src/javascript/binding.ts +++ b/rewrite-javascript/rewrite/src/javascript/binding.ts @@ -18,10 +18,10 @@ import {JS} from "./tree"; import {Cursor} from "../tree"; import {JavaScriptVisitor} from "./visitor"; import {scopeOf, walk} from "./scope"; -import {AddImportOptions, bindImport, existingImportBinding, moduleNameOf, RebindImport} from "./add-import"; +import {AddImportOptions, bindImport, existingImportBinding, memberName, moduleNameOf, RebindImport} from "./add-import"; import {RemoveImport} from "./remove-import"; import { - AmdCalleeOptions, amdBlockOf, bindAmd, calleesOf, dependencyNames, enclosingAmdBlock, lastSegment, + AmdCalleeOptions, amdBlockOf, bindAmd, calleesOf, dependencyNames, derivedBindingName, enclosingAmdBlock, parameterNames, RebindAmdDependency, RemoveAmdDependency } from "./amd"; @@ -170,7 +170,7 @@ interface ModuleObjectBinding { /** Whether the file binds its modules with `require`, which decides whether a create is possible. */ function isCommonJs(cu: JS.CompilationUnit): boolean { - if (cu.sourcePath.endsWith(".cjs")) { + if (cu.sourcePath.endsWith(".cjs") || cu.sourcePath.endsWith(".cts")) { return true; } // Node treats these as ES modules regardless of what they contain, the same way @@ -184,19 +184,33 @@ function isCommonJs(cu: JS.CompilationUnit): boolean { if (stmt.element?.kind === JS.Kind.Import) { return false; } - if (requiredModule(stmt.element) !== undefined) { + if (declarationsOf(stmt.element).some(d => requiredModule(d) !== undefined)) { requires = true; } } return requires; } -/** The module a top-level `const X = require("m")` names, for the one variable it declares. */ -function requiredModule(statement: J | undefined): string | undefined { - if (statement?.kind !== J.Kind.VariableDeclarations) { - return undefined; +/** + * The `J.VariableDeclarations` a statement declares — itself for a bare `const x = …`, one per + * declarator for `const a = …, b = …`, which the parser wraps in a `JS.ScopedVariableDeclarations` + * instead. + */ +function declarationsOf(statement: J | undefined): J.VariableDeclarations[] { + if (statement?.kind === J.Kind.VariableDeclarations) { + return [statement as J.VariableDeclarations]; + } + if (statement?.kind === JS.Kind.ScopedVariableDeclarations) { + return (statement as JS.ScopedVariableDeclarations).variables + .map(v => v.element) + .filter((v): v is J.VariableDeclarations => v?.kind === J.Kind.VariableDeclarations); } - const variables = (statement as J.VariableDeclarations).variables; + return []; +} + +/** The module a `const X = require("m")` declaration names, for the one variable it declares. */ +function requiredModule(declaration: J.VariableDeclarations): string | undefined { + const variables = declaration.variables; const initializer = variables.length === 1 ? variables[0].element?.initializer?.element : undefined; if (initializer?.kind !== J.Kind.MethodInvocation) { return undefined; @@ -214,15 +228,12 @@ function requiredModule(statement: J | undefined): string | undefined { } /** - * The module a top-level `const X = await import("m")` names, for the one variable it declares. + * The module a `const X = await import("m")` declaration names, for the one variable it declares. * A dynamic import resolves to the module namespace object, the same value `import * as X` * binds, so it shares that shape rather than getting one of its own. */ -function dynamicallyImportedModule(statement: J | undefined): string | undefined { - if (statement?.kind !== J.Kind.VariableDeclarations) { - return undefined; - } - const variables = (statement as J.VariableDeclarations).variables; +function dynamicallyImportedModule(declaration: J.VariableDeclarations): string | undefined { + const variables = declaration.variables; const initializer = variables.length === 1 ? variables[0].element?.initializer?.element : undefined; const awaited = initializer?.kind === JS.Kind.Await ? (initializer as JS.Await).expression : undefined; // `import(...)` is a keyword, not an identifier, so the parser maps it to `JS.FunctionCall` @@ -241,19 +252,20 @@ function dynamicallyImportedModule(statement: J | undefined): string | undefined : undefined; } -/** Bindings from top-level `const X = ` statements, all of one shape. */ +/** Bindings from top-level `const X = ` declarations, all of one shape. */ function wholeModuleBindingsVia( cu: JS.CompilationUnit, - moduleOf: (statement: J | undefined) => string | undefined, + moduleOf: (declaration: J.VariableDeclarations) => string | undefined, shape: ModuleObjectBinding["shape"] ): ModuleObjectBinding[] { const bindings: ModuleObjectBinding[] = []; for (const stmt of cu.statements) { - const module = moduleOf(stmt.element); - const name = module === undefined ? undefined : - (stmt.element as J.VariableDeclarations).variables[0]?.element?.name; - if (module !== undefined && name?.kind === J.Kind.Identifier) { - bindings.push({name: (name as J.Identifier).simpleName, module, shape}); + for (const declaration of declarationsOf(stmt.element)) { + const module = moduleOf(declaration); + const name = declaration.variables[0]?.element?.name; + if (module !== undefined && name?.kind === J.Kind.Identifier) { + bindings.push({name: (name as J.Identifier).simpleName, module, shape}); + } } } return bindings; @@ -314,10 +326,11 @@ export function maybeBind( options = {module: options}; } const module = moduleNameOf(options.module); + const key = memberName(options.member); const amd = enclosingAmdBlock(visitor, options); if (amd !== undefined) { - if (options.member !== undefined || options.sideEffectOnly) { + if (key !== undefined || options.sideEffectOnly) { // Nor can a factory parameter load a module without binding it to a name. return undefined; } @@ -325,22 +338,28 @@ export function maybeBind( return bindAmd(visitor, amd, module, options.preferredName, namesInScope, calleesOf(options)); } - const isWholeModule = !options.sideEffectOnly && (options.member === undefined || options.member === "*"); + const isWholeModule = !options.sideEffectOnly && (key === undefined || key === "*"); if (isWholeModule) { const cu = compilationUnitOf(visitor); const bound = cu && moduleObjectBindings(cu).find(b => - b.module === module && answersWholeModuleRequest(b, options.member === "*")); + b.module === module && answersWholeModuleRequest(b, key === "*")); if (bound !== undefined) { return bound.name; } } + if (isWholeModule && options.preferredName === undefined && derivedBindingName(module) === undefined) { + // The module's last path segment is not a legal identifier, and the caller named no + // preference of its own — there is no name left to bind it to. + return undefined; + } + // `bindImport`'s own lookup finds and reuses a member-specific binding on its own, so // refusal here only has to gate the point where it would create a new one. const refuseCreate = moduleBindings(visitor, options).moduleSystem === "commonjs"; return bindImport(visitor, { ...options, - preferredName: options.preferredName ?? (isWholeModule ? lastSegment(module) : undefined) + preferredName: options.preferredName ?? (isWholeModule ? derivedBindingName(module) : undefined) }, refuseCreate); } @@ -353,16 +372,24 @@ export function maybeUnbind(visitor: JavaScriptVisitor, options: MaybeUnbin if (typeof options === "string") { options = {module: options}; } - for (const v of visitor.afterVisit || []) { - if (v instanceof RemoveImport && v.module === options.module && v.member === options.member) { - return; - } + const callees = calleesOf(options); + const queued = visitor.afterVisit || []; + if (!queued.some(v => v instanceof RemoveImport && v.module === options.module && v.member === options.member)) { + visitor.afterVisit.push(new RemoveImport(options.module, options.member)); } - visitor.afterVisit.push(new RemoveImport(options.module, options.member)); // Both queue unconditionally so the caller need not know which lane the file uses: each // visitor removes whatever matching construct it finds, ESM import or AMD dependency, and a - // file with both gets both removed. - visitor.afterVisit.push(new RemoveAmdDependency(options.module, options.member, calleesOf(options))); + // file with both gets both removed. `amdCallee` says which block a call means, not how + // anything prints, so — unlike `preferredName`/`quoteStyle` on the bind side — it is part of + // the dedup key. + if (!queued.some(v => v instanceof RemoveAmdDependency && v.module === options.module && + v.member === options.member && sameCallees(v.callees, callees))) { + visitor.afterVisit.push(new RemoveAmdDependency(options.module, options.member, callees)); + } +} + +function sameCallees(a: readonly string[], b: readonly string[]): boolean { + return a.length === b.length && a.every((callee, i) => callee === b[i]); } /** @@ -375,7 +402,7 @@ export function maybeUnbind(visitor: JavaScriptVisitor, options: MaybeUnbin export function maybeRebind(visitor: JavaScriptVisitor, options: MaybeRebindOptions): string | undefined { const amd = enclosingAmdBlock(visitor, options); if (amd !== undefined) { - if (options.from.member !== undefined || options.to.member !== undefined) { + if (memberName(options.from.member) !== undefined || memberName(options.to.member) !== undefined) { return undefined; } const index = dependencyNames(amd.block).indexOf(options.from.module); diff --git a/rewrite-javascript/rewrite/test/javascript/binding.test.ts b/rewrite-javascript/rewrite/test/javascript/binding.test.ts index f1fc70c389..ff80b6e705 100644 --- a/rewrite-javascript/rewrite/test/javascript/binding.test.ts +++ b/rewrite-javascript/rewrite/test/javascript/binding.test.ts @@ -93,6 +93,22 @@ describe("moduleBindings", () => { expect(seen.moduleSystem).toBe("commonjs"); }); + test("a .cts file reads as CommonJS even with no require call in it yet", async () => { + const spec = new RecipeSpec(); + const seen: {moduleSystem?: string} = {}; + spec.recipe = fromVisitor(captureBindings(seen)); + await spec.rewriteRun({...typescript(`target();`), path: "module.cts"}); + expect(seen.moduleSystem).toBe("commonjs"); + }); + + test("a require among several declarators in one statement still reads as CommonJS", async () => { + const spec = new RecipeSpec(); + const seen: {moduleSystem?: string} = {}; + spec.recipe = fromVisitor(captureBindings(seen)); + await spec.rewriteRun(javascript(`const a = require("x"), b = require("y");`)); + expect(seen.moduleSystem).toBe("commonjs"); + }); + test("an AMD block's parameters bind positionally to its dependencies", async () => { const spec = new RecipeSpec(); const seen: {moduleSystem?: string, module?: string, binding?: string} = {}; @@ -229,6 +245,25 @@ describe("maybeBind", () => { expect(bound.name).toBe("Element"); }); + test("AMD accepts member: \"default\", which names the same whole module as no member at all", async () => { + const spec = new RecipeSpec(); + const bound: {name?: string} = {}; + spec.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitMethodInvocation(m: J.MethodInvocation, p: any): Promise { + if (m.name.simpleName !== "target") { + return super.visitMethodInvocation(m, p); + } + bound.name = maybeBind(this, {module: "sap/ui/core/Element", member: "default"}); + return bound.name === undefined ? m : withReference(m, bound.name); + } + }); + await spec.rewriteRun(javascript( + `sap.ui.define(["sap/m/Button"], function (Button) { target(); });`, + `sap.ui.define(["sap/m/Button", "sap/ui/core/Element"], function (Button, Element) { Element.target(); });` + )); + expect(bound.name).toBe("Element"); + }); + test("AMD appends to an expression-bodied arrow factory same as a block-bodied one", async () => { const spec = new RecipeSpec(); const bound: {name?: string} = {}; @@ -456,6 +491,19 @@ describe("maybeBind", () => { expect(bound.name).toBe("Element"); }); + test("refuses rather than derive an illegal identifier from the module's last segment", async () => { + const spec = new RecipeSpec(); + const bound: {name?: string} = {}; + spec.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitJsCompilationUnit(cu: JS.CompilationUnit, p: any): Promise { + bound.name = maybeBind(this, "lodash-es"); + return super.visitJsCompilationUnit(cu, p); + } + }); + await spec.rewriteRun(typescript(`const x = 1;`)); + expect(bound.name).toBeUndefined(); + }); + test("ESM reuses a default import bound under another name", async () => { const spec = new RecipeSpec(); const bound: {name?: string} = {}; @@ -716,6 +764,48 @@ describe("maybeRebind", () => { )); expect(bound.name).toBeUndefined(); }); + + test("moving the default out of a mixed default+named import keeps the named siblings", async () => { + const spec = new RecipeSpec(); + spec.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitJsCompilationUnit(cu: JS.CompilationUnit, p: any): Promise { + maybeRebind(this, {from: {module: "react"}, to: {module: "preact"}}); + return super.visitJsCompilationUnit(cu, p); + } + }); + await spec.rewriteRun(typescript( + `import React, {useState, useMemo} from "react";\n\nReact.foo();\nuseState();\nuseMemo();`, + `import {useState, useMemo} from "react";\nimport React from "preact";\n\nReact.foo();\nuseState();\nuseMemo();` + )); + }); + + test("moving the namespace out of a mixed default+namespace import keeps the default", async () => { + const spec = new RecipeSpec(); + spec.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitJsCompilationUnit(cu: JS.CompilationUnit, p: any): Promise { + maybeRebind(this, {from: {module: "m", member: "*"}, to: {module: "m2", member: "*"}}); + return super.visitJsCompilationUnit(cu, p); + } + }); + await spec.rewriteRun(typescript( + `import D, * as N from "m";\n\nD();\nN.foo();`, + `import D from "m";\nimport * as N from "m2";\n\nD();\nN.foo();` + )); + }); + + test("the created replacement stays type-only, and the surviving specifier keeps its own formatting", async () => { + const spec = new RecipeSpec(); + spec.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitJsCompilationUnit(cu: JS.CompilationUnit, p: any): Promise { + maybeRebind(this, {from: {module: "m", member: "a"}, to: {module: "m2", member: "a"}}); + return super.visitJsCompilationUnit(cu, p); + } + }); + await spec.rewriteRun(typescript( + `import type { a, b} from "m";\n\nlet x: a;\nlet y: b;`, + `import type { b} from "m";\nimport type {a} from "m2";\n\nlet x: a;\nlet y: b;` + )); + }); }); function dropModule(module: string, member?: string) { @@ -774,6 +864,21 @@ describe("maybeUnbind on an AMD block", () => { `myDefine(["a/C"], function (C) { C.f(); });` )); }); + + test("two calls differing only in amdCallee each queue their own removal", async () => { + const spec = new RecipeSpec(); + spec.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitJsCompilationUnit(cu: JS.CompilationUnit, p: any): Promise { + maybeUnbind(this, {module: "a/B", amdCallee: "define"}); + maybeUnbind(this, {module: "a/B", amdCallee: "myDefine"}); + return super.visitJsCompilationUnit(cu, p); + } + }); + await spec.rewriteRun(javascript( + `define(["a/B", "a/C"], function (B, C) { C.f(); });\nmyDefine(["a/B", "a/D"], function (B, D) { D.f(); });`, + `define(["a/C"], function (C) { C.f(); });\nmyDefine(["a/D"], function (D) { D.f(); });` + )); + }); }); describe("maybeUnbind on an ESM file", () => { From 5d52f608ce6bb4fd2d6cf5cf4534f407bb19b8cc Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Fri, 28 Aug 2026 06:02:59 +0200 Subject: [PATCH 34/41] JavaScript: fix two more defects from a review of the stacked branch Finding 11: a cross-shape maybeRebind (named<->default, named<->namespace) rewrote only the module in place and left the specifier in the old shape, producing e.g. `import {act} from "new"` where the caller asked for a default import. RebindImport's in-place path can only rewrite the module and, within one clause shape, a name; it has no path that restructures the clause itself, and building one would duplicate RemoveImport's whole-statement-list, header-preserving deletion rather than reuse it. maybeRebind now refuses a cross-shape move where the source statement binds nothing else, since that's exactly when the in-place path would otherwise be taken. Finding 12: maybeAddImport's non-sideEffectOnly overload promised `string` but could return `undefined` through the CommonJS-refusal path. Widened it to `string | undefined` and let the compiler point at every caller that needed a real answer. The one production call site (Template.resolveBindings) now skips an unresolved binding instead of recording undefined, so apply()'s existing "applied without a local name" check reports it the same way it reports a caller-omitted one. The type hole was real, but its documented consequence (undefined spliced into printed source) did not reproduce: apply()'s check already catches an undefined value the same way it catches an absent key, so a naive `!` cast at the call site produces the identical thrown error. No test can currently tell that fix apart from a correct one, and the CommonJS refusal itself already has dedicated coverage, so no new test was kept for this finding. --- .../rewrite/src/javascript/binding.ts | 16 +++++++++++-- .../src/javascript/templating/template.ts | 7 +++++- .../test/javascript/add-import.test.ts | 20 ++++++++-------- .../rewrite/test/javascript/binding.test.ts | 24 +++++++++++++++++++ 4 files changed, 54 insertions(+), 13 deletions(-) diff --git a/rewrite-javascript/rewrite/src/javascript/binding.ts b/rewrite-javascript/rewrite/src/javascript/binding.ts index dbac388b41..dd99a9a47b 100644 --- a/rewrite-javascript/rewrite/src/javascript/binding.ts +++ b/rewrite-javascript/rewrite/src/javascript/binding.ts @@ -392,12 +392,21 @@ function sameCallees(a: readonly string[], b: readonly string[]): boolean { return a.length === b.length && a.every((callee, i) => callee === b[i]); } +/** Which of an import clause's three slots `member` binds — `import`, `import *`, or `import {}`. */ +function bindingShape(member: string | undefined): "default" | "namespace" | "named" { + const key = memberName(member); + return key === undefined ? "default" : key === "*" ? "namespace" : "named"; +} + /** * Moves the binding for `from` to `to`, keeping the local name it already had — the primitive * behind a member rename or a module move, so a caller never has to thread that name through a * separate unbind and bind itself. Refuses, changing nothing, where nothing binds `from`, where * `from` or `to` names a member on the AMD lane (a factory parameter binds only a whole module), - * or where completing the move on the ESM lane would have to gain an import into a CommonJS file. + * where completing the move on the ESM lane would have to gain an import into a CommonJS file, or + * where `from` and `to` differ in default/namespace/named shape and `from`'s statement binds + * nothing else — the only edit then available is in place, and an in-place edit cannot restructure + * the clause it's editing. */ export function maybeRebind(visitor: JavaScriptVisitor, options: MaybeRebindOptions): string | undefined { const amd = enclosingAmdBlock(visitor, options); @@ -419,6 +428,9 @@ export function maybeRebind(visitor: JavaScriptVisitor, options: MaybeRebin if (existing === undefined) { return undefined; } + if (existing.onlyMemberOfStatement && bindingShape(options.from.member) !== bindingShape(options.to.member)) { + return undefined; + } if (!existing.onlyMemberOfStatement && moduleBindings(visitor, options).moduleSystem === "commonjs") { return undefined; } @@ -445,7 +457,7 @@ export function maybeAddImport( export function maybeAddImport( visitor: JavaScriptVisitor, options: AddImportOptions & { sideEffectOnly?: false } -): string; +): string | undefined; export function maybeAddImport( visitor: JavaScriptVisitor, options: AddImportOptions diff --git a/rewrite-javascript/rewrite/src/javascript/templating/template.ts b/rewrite-javascript/rewrite/src/javascript/templating/template.ts index 8bf21c2f9f..74e153d299 100644 --- a/rewrite-javascript/rewrite/src/javascript/templating/template.ts +++ b/rewrite-javascript/rewrite/src/javascript/templating/template.ts @@ -305,7 +305,12 @@ export class Template { // Recognising the reference the template splices in takes attribution, which only the // import form of a context statement carries. Without one there is nothing to look for. const onlyIfReferenced = isResolvable(binding.module, dependencies); - resolved[name] = maybeAddImport(visitor, {...binding, preferredName: name, onlyIfReferenced}); + const bound = maybeAddImport(visitor, {...binding, preferredName: name, onlyIfReferenced}); + // An unresolved binding is left out rather than recorded as `undefined`, so `apply()`'s + // own "applied without a local name" check catches it, same as a caller-omitted one. + if (bound !== undefined) { + resolved[name] = bound; + } } return resolved; } diff --git a/rewrite-javascript/rewrite/test/javascript/add-import.test.ts b/rewrite-javascript/rewrite/test/javascript/add-import.test.ts index 5e5e291236..100a584105 100644 --- a/rewrite-javascript/rewrite/test/javascript/add-import.test.ts +++ b/rewrite-javascript/rewrite/test/javascript/add-import.test.ts @@ -2860,8 +2860,8 @@ describe('AddImport visitor', () => { const bound: string[] = []; spec.recipe = fromVisitor(new class extends JavaScriptVisitor { override async visitJsCompilationUnit(cu: JS.CompilationUnit, p: any): Promise { - bound.push(maybeAddImport(this, {module: 'react', member: 'useState', onlyIfReferenced: false})); - bound.push(maybeAddImport(this, {module: 'fs/promises', member: 'writeFile', onlyIfReferenced: false})); + bound.push(maybeAddImport(this, {module: 'react', member: 'useState', onlyIfReferenced: false})!); + bound.push(maybeAddImport(this, {module: 'fs/promises', member: 'writeFile', onlyIfReferenced: false})!); return super.visitJsCompilationUnit(cu, p); } }); @@ -2894,8 +2894,8 @@ describe('AddImport visitor', () => { const bound: string[] = []; spec.recipe = fromVisitor(new class extends JavaScriptVisitor { override async visitJsCompilationUnit(cu: JS.CompilationUnit, p: any): Promise { - bound.push(maybeAddImport(this, {module: 'm', member: 'join', onlyIfReferenced: false})); - bound.push(maybeAddImport(this, {module: 'n', member: 'shape', onlyIfReferenced: false})); + bound.push(maybeAddImport(this, {module: 'm', member: 'join', onlyIfReferenced: false})!); + bound.push(maybeAddImport(this, {module: 'n', member: 'shape', onlyIfReferenced: false})!); return super.visitJsCompilationUnit(cu, p); } }); @@ -2925,11 +2925,11 @@ describe('AddImport visitor', () => { const preferred: string[] = []; spec.recipe = fromVisitor(new class extends JavaScriptVisitor { override async visitJsCompilationUnit(cu: JS.CompilationUnit, p: any): Promise { - quoted.push(maybeAddImport(this, {module: 'fs', member: 'readFile', onlyIfReferenced: false})); - quoted.push(maybeAddImport(this, {module: 'fs', member: 'readFile', quoteStyle: '"', onlyIfReferenced: false})); + quoted.push(maybeAddImport(this, {module: 'fs', member: 'readFile', onlyIfReferenced: false})!); + quoted.push(maybeAddImport(this, {module: 'fs', member: 'readFile', quoteStyle: '"', onlyIfReferenced: false})!); - preferred.push(maybeAddImport(this, {module: 'theme', member: 'default', preferredName: 'Theming', onlyIfReferenced: false})); - preferred.push(maybeAddImport(this, {module: 'theme', member: 'default', preferredName: 'Th', onlyIfReferenced: false})); + preferred.push(maybeAddImport(this, {module: 'theme', member: 'default', preferredName: 'Theming', onlyIfReferenced: false})!); + preferred.push(maybeAddImport(this, {module: 'theme', member: 'default', preferredName: 'Th', onlyIfReferenced: false})!); return super.visitJsCompilationUnit(cu, p); } }); @@ -3020,7 +3020,7 @@ describe('AddImport visitor', () => { override async visitMethodInvocation(method: J.MethodInvocation, p: any): Promise { if ((method.name as J.Identifier)?.simpleName === 'anchor') { bound.push(maybeAddImport(this, - {module: `m${bound.length}`, member: 'merge', onlyIfReferenced: false})); + {module: `m${bound.length}`, member: 'merge', onlyIfReferenced: false})!); } return super.visitMethodInvocation(method, p); } @@ -3084,7 +3084,7 @@ describe('AddImport visitor', () => { override async visitMethodInvocation(method: J.MethodInvocation, p: any): Promise { if ((method.name as J.Identifier)?.simpleName === 'anchor') { bound.push(maybeAddImport(this, - {module: 'sap/base/util/merge', member: 'merge', onlyIfReferenced: false})); + {module: 'sap/base/util/merge', member: 'merge', onlyIfReferenced: false})!); } return super.visitMethodInvocation(method, p); } diff --git a/rewrite-javascript/rewrite/test/javascript/binding.test.ts b/rewrite-javascript/rewrite/test/javascript/binding.test.ts index ff80b6e705..b0a028aedb 100644 --- a/rewrite-javascript/rewrite/test/javascript/binding.test.ts +++ b/rewrite-javascript/rewrite/test/javascript/binding.test.ts @@ -806,6 +806,30 @@ describe("maybeRebind", () => { `import type { b} from "m";\nimport type {a} from "m2";\n\nlet x: a;\nlet y: b;` )); }); + + test("a rebind that would change default/named/namespace shape refuses, since the only edit available is in place", async () => { + const namedToDefault = new RecipeSpec(); + const namedToDefaultBound: {name?: string} = {}; + namedToDefault.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitJsCompilationUnit(cu: JS.CompilationUnit, p: any): Promise { + namedToDefaultBound.name = maybeRebind(this, {from: {module: "old", member: "act"}, to: {module: "new", member: "default"}}); + return super.visitJsCompilationUnit(cu, p); + } + }); + await namedToDefault.rewriteRun(typescript(`import {act} from "old";\n\nact();`)); + expect(namedToDefaultBound.name).toBeUndefined(); + + const defaultToNamed = new RecipeSpec(); + const defaultToNamedBound: {name?: string} = {}; + defaultToNamed.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitJsCompilationUnit(cu: JS.CompilationUnit, p: any): Promise { + defaultToNamedBound.name = maybeRebind(this, {from: {module: "old"}, to: {module: "new", member: "act"}}); + return super.visitJsCompilationUnit(cu, p); + } + }); + await defaultToNamed.rewriteRun(typescript(`import D from "old";\n\nD();`)); + expect(defaultToNamedBound.name).toBeUndefined(); + }); }); function dropModule(module: string, member?: string) { From 4cdcc434650641e882853f7813b1c5e28cc45376 Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Fri, 28 Aug 2026 06:12:46 +0200 Subject: [PATCH 35/41] JavaScript: close two residuals from an independent verification pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finding A: derivedBindingName's identifier regex admitted reserved words, so maybeBind(v, "a/class") derived "class" and printed unparseable output on both lanes -- the same defect class finding 5 closed for illegal identifiers, left open for legal-but-reserved ones. Added a reserved-word set covering the always-reserved keywords, the strict-mode-only ones (module code is always strict), and await/yield, which are contextual elsewhere but unsafe to bind regardless of where they're legal. Finding B: RebindImport read only the clause-level typeOnly flag, so moving one specifier out of `import {type a, b} from "m"` dropped its inline `type` marker -- parses, but wrong under verbatimModuleSyntax. Same family as finding 9, which fixed the clause level and stopped one level short of the per-specifier flag. Added namedSpecifierIsTypeOnly and OR it into the clause-level read. Both mutation-verified: reverting either fix reproduces the exact wrong output reported. derivedBindingName's doc also gained a one-line note that it accepts ASCII identifiers only, per instruction, to record a known and deliberately unfixed narrowing (non-ASCII segments like "café" now refuse where they used to bind) rather than leave it for someone to re-derive. --- .../rewrite/src/javascript/add-import.ts | 24 +++++++++++- .../rewrite/src/javascript/amd.ts | 18 +++++++-- .../rewrite/test/javascript/binding.test.ts | 38 +++++++++++++++++++ 3 files changed, 76 insertions(+), 4 deletions(-) diff --git a/rewrite-javascript/rewrite/src/javascript/add-import.ts b/rewrite-javascript/rewrite/src/javascript/add-import.ts index 4ecc1554f2..3613d0eafb 100644 --- a/rewrite-javascript/rewrite/src/javascript/add-import.ts +++ b/rewrite-javascript/rewrite/src/javascript/add-import.ts @@ -1621,6 +1621,25 @@ function importBinds(jsImport: JS.Import, module: string, member: string | undef return undefined; } +/** Whether the named specifier binding `key` carries its own inline `type`, as in `{type a, b}`. */ +function namedSpecifierIsTypeOnly(imp: JS.Import, key: string): boolean { + const namedBindings = imp.importClause?.namedBindings; + if (namedBindings?.kind !== JS.Kind.NamedImports) { + return false; + } + for (const elem of (namedBindings as JS.NamedImports).elements.elements) { + const specifierNode = elem.element.specifier; + const matches = (isIdentifier(specifierNode) && specifierNode.simpleName === key) || + (specifierNode.kind === JS.Kind.Alias && + isIdentifier((specifierNode as JS.Alias).propertyName.element) && + ((specifierNode as JS.Alias).propertyName.element as J.Identifier).simpleName === key); + if (matches) { + return elem.element.importType.element; + } + } + return false; +} + /** How many named specifiers `jsImport` carries, `0` for a default, namespace or side-effect import. */ function namedImportCount(jsImport: JS.Import): number { const namedBindings = jsImport.importClause?.namedBindings; @@ -1799,7 +1818,10 @@ export class RebindImport

extends JavaScriptVisitor

{ if (importBinds(imp, this.from.module, this.from.member) === undefined) { return imp; } - this.typeOnly = imp.importClause?.typeOnly ?? false; + // A moved named specifier's own inline `type` marks it type-only even where the clause + // it's leaving is not — the replacement needs the same answer to stay type-safe. + this.typeOnly = (imp.importClause?.typeOnly ?? false) || + (key !== undefined && key !== '*' && namedSpecifierIsTypeOnly(imp, key)); if (!isOnlyMember(imp)) { return removeBinding(imp, this.from.member); diff --git a/rewrite-javascript/rewrite/src/javascript/amd.ts b/rewrite-javascript/rewrite/src/javascript/amd.ts index fd212549bd..93962e931b 100644 --- a/rewrite-javascript/rewrite/src/javascript/amd.ts +++ b/rewrite-javascript/rewrite/src/javascript/amd.ts @@ -577,14 +577,26 @@ export function lastSegment(module: string): string { return module.substring(module.lastIndexOf("/") + 1); } +// Always-reserved words, plus the strict-mode-only ones (module code is always strict) and +// `await`/`yield`, contextual elsewhere but a trap to bind regardless of where they're legal. +const RESERVED_WORDS = new Set([ + "break", "case", "catch", "class", "const", "continue", "debugger", "default", "delete", "do", + "else", "enum", "export", "extends", "false", "finally", "for", "function", "if", "import", + "in", "instanceof", "new", "null", "return", "super", "switch", "this", "throw", "true", "try", + "typeof", "var", "void", "while", "with", + "await", "implements", "interface", "let", "package", "private", "protected", "public", + "static", "yield" +]); + /** * `lastSegment(module)`, or `undefined` where that string cannot bind a name — a scoped - * package's `@scope/my-lib`, a subpath's `lodash.merge`, or a bare `-`/`.`-bearing package name - * are all real module strings that are not legal identifiers. + * package's `@scope/my-lib`, a subpath's `lodash.merge`, a `-`/`.`-bearing name, or a reserved + * word are all real module strings that are not legal identifiers. ASCII identifiers only — + * stricter than JavaScript itself, which allows Unicode, but refusing there is safe. */ export function derivedBindingName(module: string): string | undefined { const segment = lastSegment(module); - return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(segment) ? segment : undefined; + return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(segment) && !RESERVED_WORDS.has(segment) ? segment : undefined; } function deconflict(preferred: string, taken: readonly (string | undefined)[]): string { diff --git a/rewrite-javascript/rewrite/test/javascript/binding.test.ts b/rewrite-javascript/rewrite/test/javascript/binding.test.ts index b0a028aedb..9236beba8a 100644 --- a/rewrite-javascript/rewrite/test/javascript/binding.test.ts +++ b/rewrite-javascript/rewrite/test/javascript/binding.test.ts @@ -504,6 +504,30 @@ describe("maybeBind", () => { expect(bound.name).toBeUndefined(); }); + test("refuses rather than derive a reserved word from the module's last segment", async () => { + const hardKeyword = new RecipeSpec(); + const hardKeywordBound: {name?: string} = {}; + hardKeyword.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitJsCompilationUnit(cu: JS.CompilationUnit, p: any): Promise { + hardKeywordBound.name = maybeBind(this, "a/class"); + return super.visitJsCompilationUnit(cu, p); + } + }); + await hardKeyword.rewriteRun(typescript(`const x = 1;`)); + expect(hardKeywordBound.name).toBeUndefined(); + + const contextualKeyword = new RecipeSpec(); + const contextualKeywordBound: {name?: string} = {}; + contextualKeyword.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitJsCompilationUnit(cu: JS.CompilationUnit, p: any): Promise { + contextualKeywordBound.name = maybeBind(this, "a/await"); + return super.visitJsCompilationUnit(cu, p); + } + }); + await contextualKeyword.rewriteRun(typescript(`const x = 1;`)); + expect(contextualKeywordBound.name).toBeUndefined(); + }); + test("ESM reuses a default import bound under another name", async () => { const spec = new RecipeSpec(); const bound: {name?: string} = {}; @@ -830,6 +854,20 @@ describe("maybeRebind", () => { await defaultToNamed.rewriteRun(typescript(`import D from "old";\n\nD();`)); expect(defaultToNamedBound.name).toBeUndefined(); }); + + test("a moved specifier's own inline type marker survives even where the clause it left is not type-only", async () => { + const spec = new RecipeSpec(); + spec.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitJsCompilationUnit(cu: JS.CompilationUnit, p: any): Promise { + maybeRebind(this, {from: {module: "m", member: "a"}, to: {module: "m2", member: "a"}}); + return super.visitJsCompilationUnit(cu, p); + } + }); + await spec.rewriteRun(typescript( + `import {type a, b} from "m";\n\nlet x: a;\nlet y: b;`, + `import {b} from "m";\nimport type {a} from "m2";\n\nlet x: a;\nlet y: b;` + )); + }); }); function dropModule(module: string, member?: string) { From b124388feb40c4c0aa56f39619eb9b5aa9f6c4f6 Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Fri, 28 Aug 2026 06:36:54 +0200 Subject: [PATCH 36/41] JavaScript: share the module-binding helpers through scope.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `cursorOf`, `compilationUnitOf`, `deconflict` and `declarationsOf` each existed in two or three copies across binding.ts, add-import.ts and amd.ts. scope.ts imports none of them, so it hosts all four without a cycle; `deconflict` unifies its Set and array callers on a membership predicate, and `compilationUnitOf` takes either a cursor or a visitor. binding.ts's `requiredModule` re-derived what add-import.ts's `requiredModuleOf` already computes, and had drifted: it read `.value` directly where `moduleNameOf` reunites `valueSource` with `unicodeEscapes`. A specifier carrying an escape — `require("\u0066oo")` — therefore bound the quoted source rather than the module name `foo`. It now delegates. `maybeBind` and `maybeRebind` built a whole `ModuleBindings` — re-running the AMD lookup they had already resolved, plus a statements scan and the top-level-await walk — only to read `moduleSystem === "commonjs"`. Both call `isCommonJs` directly. Three copies of "does this named specifier bind `key`" collapse into `namedSpecifierImports`; one had drifted to a raw kind check. --- .../rewrite/src/javascript/add-import.ts | 89 +++++++------------ .../rewrite/src/javascript/amd.ts | 21 +---- .../rewrite/src/javascript/binding.ts | 57 +++--------- .../rewrite/src/javascript/scope.ts | 43 +++++++++ .../rewrite/test/javascript/binding.test.ts | 14 ++- 5 files changed, 92 insertions(+), 132 deletions(-) diff --git a/rewrite-javascript/rewrite/src/javascript/add-import.ts b/rewrite-javascript/rewrite/src/javascript/add-import.ts index 3613d0eafb..ad50cdd105 100644 --- a/rewrite-javascript/rewrite/src/javascript/add-import.ts +++ b/rewrite-javascript/rewrite/src/javascript/add-import.ts @@ -4,8 +4,7 @@ import {JS, JSX} from "./tree"; import {randomId} from "../uuid"; import {emptyMarkers, markers} from "../markers"; import {getStyle, PrettierStyle, SpacesStyle, StyleKind} from "./style"; -import {Cursor} from "../tree"; -import {bindingNames, namesDeclaredIn} from "./scope"; +import {bindingNames, compilationUnitOf, cursorOf, declarationsOf, deconflict, namesDeclaredIn} from "./scope"; import {create as produce, Draft} from "mutative"; export type QuoteChar = "'" | '"'; @@ -147,7 +146,8 @@ export function bindImport( // Only the module scope answers for a name, but any scope in the file occupies one. The queue // gives every later request for this module the name chosen here, so it has to clear the scopes // those references will sit in, which are not known yet. - const name = deconflict(derived, takenNames(namesDeclaredIn(cu), visitor)); + const taken = takenNames(namesDeclaredIn(cu), visitor); + const name = deconflict(derived, candidate => taken.has(candidate)); visitor.afterVisit.push(new AddImport(options, name)); return name; } @@ -212,16 +212,6 @@ interface ModuleScopeBinding { typeOnly?: boolean; } -function cursorOf(visitor: JavaScriptVisitor): Cursor | undefined { - // `cursor` is protected on `TreeVisitor`, and `bindImport`/`maybeRemoveImport` are free - // functions, so reaching it takes a cast. - return (visitor as unknown as { cursor?: Cursor }).cursor; -} - -function compilationUnitOf(cursor: Cursor): JS.CompilationUnit | undefined { - return cursor.firstEnclosing((v): v is JS.CompilationUnit => v?.kind === JS.Kind.CompilationUnit); -} - /** What the file's imports and `require`s bind at module scope, and the module each name comes from. */ function moduleScopeBindings(cu: JS.CompilationUnit): ModuleScopeBinding[] { const bindings: ModuleScopeBinding[] = []; @@ -237,20 +227,12 @@ function moduleScopeBindings(cu: JS.CompilationUnit): ModuleScopeBinding[] { for (const stmt of cu.statements) { const statement = stmt.element; - switch (statement?.kind) { - case JS.Kind.Import: - bindings.push(...importBindings(statement as JS.Import)); - break; - case J.Kind.VariableDeclarations: - declaredByVariables(statement as J.VariableDeclarations); - break; - case JS.Kind.ScopedVariableDeclarations: - for (const variable of (statement as JS.ScopedVariableDeclarations).variables) { - if (variable.element?.kind === J.Kind.VariableDeclarations) { - declaredByVariables(variable.element as J.VariableDeclarations); - } - } - break; + if (statement?.kind === JS.Kind.Import) { + bindings.push(...importBindings(statement as JS.Import)); + continue; + } + for (const declaration of declarationsOf(statement)) { + declaredByVariables(declaration); } } @@ -274,7 +256,7 @@ function isRequireCall(methodInv: J.MethodInvocation): boolean { * The module a `require(...)` call loads. `obj.require('x')` selects a method rather than loading a * module, and a specifier that is not a string literal names none that can be read here. */ -function requiredModuleOf(methodInv: J.MethodInvocation): string | undefined { +export function requiredModuleOf(methodInv: J.MethodInvocation): string | undefined { if (!isRequireCall(methodInv)) { return undefined; } @@ -366,17 +348,6 @@ function takenNames(inScope: ReadonlySet, visitor: JavaScriptVisitor): string { - if (!taken.has(derived)) { - return derived; - } - let suffix = 1; - while (taken.has(`${derived}_${suffix}`)) { - suffix++; - } - return `${derived}_${suffix}`; -} - /** * The parser lifts surrogate pairs out of `valueSource` into `unicodeEscapes` and, when it does, * sets `value` to the quoted source (`parser.ts` `mapLiteral`), so neither field alone is the @@ -1607,20 +1578,32 @@ function importBinds(jsImport: JS.Import, module: string, member: string | undef } for (const elem of (namedBindings as JS.NamedImports).elements.elements) { const specifierNode = elem.element.specifier; - if (isIdentifier(specifierNode) && specifierNode.simpleName === key) { + if (!namedSpecifierImports(specifierNode, key)) { + continue; + } + if (isIdentifier(specifierNode)) { return specifierNode.simpleName; } - if (specifierNode.kind === JS.Kind.Alias) { - const alias = specifierNode as JS.Alias; - const propertyName = alias.propertyName.element; - if (isIdentifier(propertyName) && propertyName.simpleName === key && isIdentifier(alias.alias)) { - return alias.alias.simpleName; - } + const alias = (specifierNode as JS.Alias).alias; + if (isIdentifier(alias)) { + return alias.simpleName; } } return undefined; } +/** Whether `specifier` imports the member `key`, under whatever local name it binds it to. */ +function namedSpecifierImports(specifier: JS.ImportSpecifier["specifier"], key: string): boolean { + if (isIdentifier(specifier)) { + return specifier.simpleName === key; + } + if (specifier.kind === JS.Kind.Alias) { + const propertyName = (specifier as JS.Alias).propertyName.element; + return isIdentifier(propertyName) && propertyName.simpleName === key; + } + return false; +} + /** Whether the named specifier binding `key` carries its own inline `type`, as in `{type a, b}`. */ function namedSpecifierIsTypeOnly(imp: JS.Import, key: string): boolean { const namedBindings = imp.importClause?.namedBindings; @@ -1628,12 +1611,7 @@ function namedSpecifierIsTypeOnly(imp: JS.Import, key: string): boolean { return false; } for (const elem of (namedBindings as JS.NamedImports).elements.elements) { - const specifierNode = elem.element.specifier; - const matches = (isIdentifier(specifierNode) && specifierNode.simpleName === key) || - (specifierNode.kind === JS.Kind.Alias && - isIdentifier((specifierNode as JS.Alias).propertyName.element) && - ((specifierNode as JS.Alias).propertyName.element as J.Identifier).simpleName === key); - if (matches) { + if (namedSpecifierImports(elem.element.specifier, key)) { return elem.element.importType.element; } } @@ -1761,12 +1739,7 @@ function removeBinding(jsImport: JS.Import, member: string | undefined): JS.Impo const formatter = new ElementRemovalFormatter(); const kept: J.RightPadded[] = []; for (const entry of namedImports.elements.elements) { - const specifierNode = entry.element.specifier; - const matches = (specifierNode.kind === J.Kind.Identifier && specifierNode.simpleName === key) || - (specifierNode.kind === JS.Kind.Alias && - (specifierNode as JS.Alias).propertyName.element.kind === J.Kind.Identifier && - ((specifierNode as JS.Alias).propertyName.element as J.Identifier).simpleName === key); - if (matches) { + if (namedSpecifierImports(entry.element.specifier, key)) { formatter.markRemoved(entry.element); } else { kept.push({...entry, element: formatter.processKept(entry.element)}); diff --git a/rewrite-javascript/rewrite/src/javascript/amd.ts b/rewrite-javascript/rewrite/src/javascript/amd.ts index 93962e931b..19910ab96b 100644 --- a/rewrite-javascript/rewrite/src/javascript/amd.ts +++ b/rewrite-javascript/rewrite/src/javascript/amd.ts @@ -29,7 +29,7 @@ import { TrailingComma } from "../java"; import {JS} from "./tree"; -import {Cursor} from "../tree"; +import {cursorOf, deconflict} from "./scope"; import {JavaScriptVisitor} from "./visitor"; import {ExecutionContext} from "../execution"; @@ -547,11 +547,6 @@ export function calleesOf(options?: AmdCalleeOptions): readonly string[] { return callee === undefined ? DEFAULT_AMD_CALLEES : typeof callee === "string" ? [callee] : callee; } -/** `cursor` is protected on `TreeVisitor` and this API is free functions, so reaching it takes a cast. */ -function cursorOf(visitor: JavaScriptVisitor): Cursor | undefined { - return (visitor as unknown as {cursor?: Cursor}).cursor; -} - /** The nearest AMD block the cursor sits inside, which is the one a binding belongs to. */ export function enclosingAmdBlock( visitor: JavaScriptVisitor, @@ -599,18 +594,6 @@ export function derivedBindingName(module: string): string | undefined { return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(segment) && !RESERVED_WORDS.has(segment) ? segment : undefined; } -function deconflict(preferred: string, taken: readonly (string | undefined)[]): string { - if (!taken.includes(preferred)) { - return preferred; - } - for (let suffix = 1; ; suffix++) { - const candidate = `${preferred}_${suffix}`; - if (!taken.includes(candidate)) { - return candidate; - } - } -} - /** Queued reservations already targeting this block: the shared source for both dedup and deconfliction. */ function queuedFor(visitor: JavaScriptVisitor, blockId: UUID): AddAmdDependency[] { return visitor.afterVisit.filter((v): v is AddAmdDependency => v instanceof AddAmdDependency && v.blockId === blockId); @@ -657,7 +640,7 @@ export function bindAmd( return undefined; } const taken = [...bindings, ...namesInScope, ...queued.map(v => v.binding)]; - const binding = deconflict(preferredName ?? derivedBindingName(module)!, taken); + const binding = deconflict(preferredName ?? derivedBindingName(module)!, candidate => taken.includes(candidate)); visitor.afterVisit.push(new AddAmdDependency(amd.call.id, module, binding, callees)); return binding; } diff --git a/rewrite-javascript/rewrite/src/javascript/binding.ts b/rewrite-javascript/rewrite/src/javascript/binding.ts index dd99a9a47b..ba617f1595 100644 --- a/rewrite-javascript/rewrite/src/javascript/binding.ts +++ b/rewrite-javascript/rewrite/src/javascript/binding.ts @@ -15,10 +15,9 @@ */ import {J} from "../java"; import {JS} from "./tree"; -import {Cursor} from "../tree"; import {JavaScriptVisitor} from "./visitor"; -import {scopeOf, walk} from "./scope"; -import {AddImportOptions, bindImport, existingImportBinding, memberName, moduleNameOf, RebindImport} from "./add-import"; +import {compilationUnitOf, cursorOf, declarationsOf, scopeOf, walk} from "./scope"; +import {AddImportOptions, bindImport, existingImportBinding, memberName, moduleNameOf, RebindImport, requiredModuleOf} from "./add-import"; import {RemoveImport} from "./remove-import"; import { AmdCalleeOptions, amdBlockOf, bindAmd, calleesOf, dependencyNames, derivedBindingName, enclosingAmdBlock, @@ -74,16 +73,6 @@ export interface ModuleBindings { readonly moduleSystem: "esm" | "amd" | "commonjs" | "none"; } -/** `cursor` is protected on `TreeVisitor` and this API is free functions, so reaching it takes a cast. */ -function cursorOf(visitor: JavaScriptVisitor): Cursor | undefined { - return (visitor as unknown as {cursor?: Cursor}).cursor; -} - -function compilationUnitOf(visitor: JavaScriptVisitor): JS.CompilationUnit | undefined { - return cursorOf(visitor)?.firstEnclosing( - (v): v is JS.CompilationUnit => v?.kind === JS.Kind.CompilationUnit); -} - export function moduleBindings( visitor: JavaScriptVisitor, options?: AmdCalleeOptions @@ -191,39 +180,12 @@ function isCommonJs(cu: JS.CompilationUnit): boolean { return requires; } -/** - * The `J.VariableDeclarations` a statement declares — itself for a bare `const x = …`, one per - * declarator for `const a = …, b = …`, which the parser wraps in a `JS.ScopedVariableDeclarations` - * instead. - */ -function declarationsOf(statement: J | undefined): J.VariableDeclarations[] { - if (statement?.kind === J.Kind.VariableDeclarations) { - return [statement as J.VariableDeclarations]; - } - if (statement?.kind === JS.Kind.ScopedVariableDeclarations) { - return (statement as JS.ScopedVariableDeclarations).variables - .map(v => v.element) - .filter((v): v is J.VariableDeclarations => v?.kind === J.Kind.VariableDeclarations); - } - return []; -} - /** The module a `const X = require("m")` declaration names, for the one variable it declares. */ function requiredModule(declaration: J.VariableDeclarations): string | undefined { const variables = declaration.variables; const initializer = variables.length === 1 ? variables[0].element?.initializer?.element : undefined; - if (initializer?.kind !== J.Kind.MethodInvocation) { - return undefined; - } - const call = initializer as J.MethodInvocation; - // `obj.require('x')` selects a method rather than loading a module, matching add-import.ts's - // own `requiredModuleOf`. - if (call.select || call.name.simpleName !== "require") { - return undefined; - } - const argument = call.arguments.elements[0]?.element; - return argument?.kind === J.Kind.Literal && typeof (argument as J.Literal).value === "string" - ? (argument as J.Literal).value as string + return initializer?.kind === J.Kind.MethodInvocation + ? requiredModuleOf(initializer as J.MethodInvocation) : undefined; } @@ -338,9 +300,9 @@ export function maybeBind( return bindAmd(visitor, amd, module, options.preferredName, namesInScope, calleesOf(options)); } + const cu = compilationUnitOf(visitor); const isWholeModule = !options.sideEffectOnly && (key === undefined || key === "*"); if (isWholeModule) { - const cu = compilationUnitOf(visitor); const bound = cu && moduleObjectBindings(cu).find(b => b.module === module && answersWholeModuleRequest(b, key === "*")); if (bound !== undefined) { @@ -356,7 +318,7 @@ export function maybeBind( // `bindImport`'s own lookup finds and reuses a member-specific binding on its own, so // refusal here only has to gate the point where it would create a new one. - const refuseCreate = moduleBindings(visitor, options).moduleSystem === "commonjs"; + const refuseCreate = cu !== undefined && isCommonJs(cu); return bindImport(visitor, { ...options, preferredName: options.preferredName ?? (isWholeModule ? derivedBindingName(module) : undefined) @@ -424,14 +386,17 @@ export function maybeRebind(visitor: JavaScriptVisitor, options: MaybeRebin } const cu = compilationUnitOf(visitor); - const existing = cu && existingImportBinding(cu, options.from.module, options.from.member); + if (cu === undefined) { + return undefined; + } + const existing = existingImportBinding(cu, options.from.module, options.from.member); if (existing === undefined) { return undefined; } if (existing.onlyMemberOfStatement && bindingShape(options.from.member) !== bindingShape(options.to.member)) { return undefined; } - if (!existing.onlyMemberOfStatement && moduleBindings(visitor, options).moduleSystem === "commonjs") { + if (!existing.onlyMemberOfStatement && isCommonJs(cu)) { return undefined; } visitor.afterVisit.push(new RebindImport(options.from, options.to, existing.localName)); diff --git a/rewrite-javascript/rewrite/src/javascript/scope.ts b/rewrite-javascript/rewrite/src/javascript/scope.ts index 7f27ce7326..781b55739f 100644 --- a/rewrite-javascript/rewrite/src/javascript/scope.ts +++ b/rewrite-javascript/rewrite/src/javascript/scope.ts @@ -16,6 +16,8 @@ import {Cursor, isTree} from "../tree"; import {J} from "../java"; import {JS} from "./tree"; +// scope.ts sits below the visitor, so this stays type-only. +import type {JavaScriptVisitor} from "./visitor"; /** The names code at some position can reach unqualified. */ export interface Scope { @@ -288,3 +290,44 @@ export function walk(node: unknown, visit: (node: any) => boolean): void { function unwrap(node: any): any { return node?.kind === J.Kind.RightPadded || node?.kind === J.Kind.LeftPadded ? unwrap(node.element) : node; } + +/** `cursor` is protected on `TreeVisitor` and these APIs are free functions, so reaching it takes a cast. */ +export function cursorOf(visitor: JavaScriptVisitor): Cursor | undefined { + return (visitor as unknown as {cursor?: Cursor}).cursor; +} + +/** The compilation unit a cursor sits in, or the one a visitor is currently positioned in. */ +export function compilationUnitOf(from: Cursor | JavaScriptVisitor): JS.CompilationUnit | undefined { + const cursor = from instanceof Cursor ? from : cursorOf(from); + return cursor?.firstEnclosing((v): v is JS.CompilationUnit => v?.kind === JS.Kind.CompilationUnit); +} + +/** `preferred`, or the first `preferred_N` that `isTaken` rejects, so a new name never shadows one in scope. */ +export function deconflict(preferred: string, isTaken: (name: string) => boolean): string { + if (!isTaken(preferred)) { + return preferred; + } + for (let suffix = 1; ; suffix++) { + const candidate = `${preferred}_${suffix}`; + if (!isTaken(candidate)) { + return candidate; + } + } +} + +/** + * The `J.VariableDeclarations` a statement declares — itself for a bare `const x = …`, one per + * declarator for `const a = …, b = …`, which the parser wraps in a `JS.ScopedVariableDeclarations` + * instead. + */ +export function declarationsOf(statement: J | undefined): J.VariableDeclarations[] { + if (statement?.kind === J.Kind.VariableDeclarations) { + return [statement as J.VariableDeclarations]; + } + if (statement?.kind === JS.Kind.ScopedVariableDeclarations) { + return (statement as JS.ScopedVariableDeclarations).variables + .map(v => v.element) + .filter((v): v is J.VariableDeclarations => v?.kind === J.Kind.VariableDeclarations); + } + return []; +} diff --git a/rewrite-javascript/rewrite/test/javascript/binding.test.ts b/rewrite-javascript/rewrite/test/javascript/binding.test.ts index 9236beba8a..a8648806a0 100644 --- a/rewrite-javascript/rewrite/test/javascript/binding.test.ts +++ b/rewrite-javascript/rewrite/test/javascript/binding.test.ts @@ -10,15 +10,11 @@ import {ExecutionContext} from "../../src"; function captureBindings(seen: {moduleSystem?: string, module?: string, binding?: string}, localName: string = "Button", moduleName: string = "sap/m/Button") { - return new class extends JavaScriptVisitor { - override async visitJsCompilationUnit(cu: JS.CompilationUnit, p: any): Promise { - const bindings = moduleBindings(this); - seen.moduleSystem = bindings.moduleSystem; - seen.module = bindings.moduleOf(localName); - seen.binding = bindings.bindingOf(moduleName); - return super.visitJsCompilationUnit(cu, p); - } - }; + return captureModuleBindings(bindings => { + seen.moduleSystem = bindings.moduleSystem; + seen.module = bindings.moduleOf(localName); + seen.binding = bindings.bindingOf(moduleName); + }); } /** Runs `extract` with the file's `ModuleBindings`, visited from the compilation unit itself. */ From 80fcc20d8e525db24c8354a34985c39b70085c0c Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Fri, 28 Aug 2026 08:20:45 +0200 Subject: [PATCH 37/41] JavaScript: Template.resolveBindings calls maybeBind The templating engine is the framework's own consumer of this API and was left on the deprecated entry point. `MaybeBindOptions` extends `AddImportOptions`, so the call site is unchanged apart from the name, and `resolveBindings` already handles the `undefined` return. --- .../rewrite/src/javascript/templating/template.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rewrite-javascript/rewrite/src/javascript/templating/template.ts b/rewrite-javascript/rewrite/src/javascript/templating/template.ts index 74e153d299..378f6617a0 100644 --- a/rewrite-javascript/rewrite/src/javascript/templating/template.ts +++ b/rewrite-javascript/rewrite/src/javascript/templating/template.ts @@ -17,7 +17,7 @@ import {Cursor, Tree} from '../..'; import {J} from '../../java'; import {ApplyOptions, Parameter, TemplateOptions, TemplateParameter} from './types'; import {bindingContextStatement, isResolvable} from './bindings'; -import {maybeAddImport} from '../binding'; +import {maybeBind} from '../binding'; import {JavaScriptVisitor} from '../visitor'; import {MatchResult} from './pattern'; import {generateCacheKey, globalAstCache, WRAPPERS_MAP_SYMBOL} from './utils'; @@ -305,7 +305,7 @@ export class Template { // Recognising the reference the template splices in takes attribution, which only the // import form of a context statement carries. Without one there is nothing to look for. const onlyIfReferenced = isResolvable(binding.module, dependencies); - const bound = maybeAddImport(visitor, {...binding, preferredName: name, onlyIfReferenced}); + const bound = maybeBind(visitor, {...binding, preferredName: name, onlyIfReferenced}); // An unresolved binding is left out rather than recorded as `undefined`, so `apply()`'s // own "applied without a local name" check catches it, same as a caller-omitted one. if (bound !== undefined) { From cfc7aa8cdec1f8c7b784123c7d7c569d9bfefde1 Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Fri, 28 Aug 2026 09:42:19 +0200 Subject: [PATCH 38/41] JavaScript: a template's context statements declare its module bindings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A template said which modules it needed twice — once as `context`, so its code could be typed, and again as a structured `bindings` map, so they could be bound in the file being edited. The engine then guessed which of the two a context statement was, from `isResolvable`: a module whose first path segment was not a key in `dependencies` was parsed against `declare const X: any` rather than an import. That rule reads a package name off a specifier, which holds for `lodash/fp` and fails for any package declaring its modules ambiently, where the package supplying the types is not the prefix of the modules it declares. The context statements are already parsed with the template, so what they bind can be read rather than guessed. `moduleScopeBindings` — which this branch already uses to read a file's bindings — reads the template's context the same way: an `import` or `require` states a binding, anything else types the template without asking for one. `onlyIfReferenced` follows from whether the parsed import was actually attributed, which is measured where `isResolvable` was inferred. `resolveBindings` is async, since the bindings now come from the parse. It reads through the cache entry the template parse already populates, so this costs no second parse. Supplying names is what asks for binding; without them a context import only types the template, which is what a caller doing its own binding wants from one. `types` names type packages to load whose declarations nothing imports. TypeScript reads `@types/*` on its own and everything else only when named, so a package declaring modules ambiently resolves only with it set. Absent `types` leaves the compiler's default, so existing parses are unchanged. It is keyed into both template caches and the parser pool, distinguishing an empty list — load nothing — from absence. The structured `bindings` option, `ModuleBinding`, `isResolvable` and `bindingContextStatement` are removed rather than deprecated; they have only ever been in dated snapshot builds. `bindings` now has one meaning, the local names a template resolved to. --- .../rewrite/src/javascript/add-import.ts | 4 +- .../rewrite/src/javascript/parser.ts | 10 ++- .../src/javascript/templating/bindings.ts | 30 ------- .../src/javascript/templating/engine.ts | 90 +++++++++++++------ .../src/javascript/templating/index.ts | 1 - .../src/javascript/templating/rewrite.ts | 3 +- .../src/javascript/templating/template.ts | 64 ++++++++----- .../src/javascript/templating/types.ts | 22 ++--- .../src/javascript/templating/utils.ts | 6 +- .../templating/dependencies.test.ts | 46 +++++++++- .../templating/template-bindings.test.ts | 39 ++++---- 11 files changed, 191 insertions(+), 124 deletions(-) diff --git a/rewrite-javascript/rewrite/src/javascript/add-import.ts b/rewrite-javascript/rewrite/src/javascript/add-import.ts index ad50cdd105..378ccd5213 100644 --- a/rewrite-javascript/rewrite/src/javascript/add-import.ts +++ b/rewrite-javascript/rewrite/src/javascript/add-import.ts @@ -204,7 +204,7 @@ export function memberName(member: string | undefined): string | undefined { } /** A name introduced into the file's module scope, and where it came from when that is an import. */ -interface ModuleScopeBinding { +export interface ModuleScopeBinding { name: string; module?: string; /** Carries the {@link AddImportOptions.member} spelling, so `undefined` means a default import. */ @@ -213,7 +213,7 @@ interface ModuleScopeBinding { } /** What the file's imports and `require`s bind at module scope, and the module each name comes from. */ -function moduleScopeBindings(cu: JS.CompilationUnit): ModuleScopeBinding[] { +export function moduleScopeBindings(cu: JS.CompilationUnit): ModuleScopeBinding[] { const bindings: ModuleScopeBinding[] = []; const declaredByVariables = (varDecl: J.VariableDeclarations): void => { diff --git a/rewrite-javascript/rewrite/src/javascript/parser.ts b/rewrite-javascript/rewrite/src/javascript/parser.ts index 857c35e505..4c4a993232 100644 --- a/rewrite-javascript/rewrite/src/javascript/parser.ts +++ b/rewrite-javascript/rewrite/src/javascript/parser.ts @@ -54,6 +54,12 @@ import SpreadAttribute = JSX.SpreadAttribute; export interface JavaScriptParserOptions extends ParserOptions { styles?: NamedStyles[], sourceFileCache?: Map, + /** + * Type packages to load whose declarations nothing imports. Unset leaves TypeScript's default, + * which reads `@types/*` and nothing else; a package declaring its modules ambiently needs + * naming here for those declarations to be in scope. + */ + types?: string[], } function getScriptKindFromFileName(fileName: string): ts.ScriptKind { @@ -86,6 +92,7 @@ export class JavaScriptParser extends Parser { relativeTo, styles, sourceFileCache, + types, }: JavaScriptParserOptions = {}, ) { super({ctx, relativeTo}); @@ -108,7 +115,8 @@ export class JavaScriptParser extends Parser { emitDecoratorMetadata: true, forceConsistentCasingInFileNames: false, jsx: ts.JsxEmit.Preserve, - baseUrl: relativeTo || process.cwd() + baseUrl: relativeTo || process.cwd(), + ...(types ? {types} : {}) }; this.styles = styles; this.sourceFileCache = sourceFileCache; diff --git a/rewrite-javascript/rewrite/src/javascript/templating/bindings.ts b/rewrite-javascript/rewrite/src/javascript/templating/bindings.ts index 56f396bd80..932624b62a 100644 --- a/rewrite-javascript/rewrite/src/javascript/templating/bindings.ts +++ b/rewrite-javascript/rewrite/src/javascript/templating/bindings.ts @@ -16,36 +16,6 @@ import {J, Type} from '../../java'; import {JavaScriptVisitor} from '../visitor'; import {Cursor} from '../../tree'; -import {ModuleBinding} from './types'; - -/** - * Whether the template's dependencies bring a workspace that could resolve `module`. - */ -export function isResolvable(module: string, dependencies: Record): boolean { - const segments = module.split('/'); - const pkg = module.startsWith('@') ? segments.slice(0, 2).join('/') : segments[0]; - return Object.prototype.hasOwnProperty.call(dependencies, pkg); -} - -/** - * What a declared binding's name is parsed against, ahead of the template and so out of its output. - * An import is what carries attribution, and costs a module resolution to get it; a declaration - * names the binding for a module no workspace could have resolved anyway. - */ -export function bindingContextStatement(name: string, binding: ModuleBinding, dependencies: Record): string { - if (!isResolvable(binding.module, dependencies)) { - return binding.typeOnly ? `type ${name} = any;` : `declare const ${name}: any;`; - } - const type = binding.typeOnly ? 'type ' : ''; - if (binding.member === '*') { - return `import ${type}* as ${name} from '${binding.module}';`; - } - if (binding.member === undefined || binding.member === 'default') { - return `import ${type}${name} from '${binding.module}';`; - } - const specifier = binding.member === name ? name : `${binding.member} as ${name}`; - return `import ${type}{${specifier}} from '${binding.module}';`; -} /** * Renames the identifiers a template uses for its declared bindings to the names the file diff --git a/rewrite-javascript/rewrite/src/javascript/templating/engine.ts b/rewrite-javascript/rewrite/src/javascript/templating/engine.ts index aa7c50313b..934ba56532 100644 --- a/rewrite-javascript/rewrite/src/javascript/templating/engine.ts +++ b/rewrite-javascript/rewrite/src/javascript/templating/engine.ts @@ -28,6 +28,14 @@ import {isExpression, isStatement} from '../parser-utils'; import {randomId} from '../../uuid'; import ts from "typescript"; import {DependencyWorkspace} from "../dependency-workspace"; +import {ModuleScopeBinding, moduleScopeBindings} from '../add-import'; +import {walk} from '../scope'; +import {isIdentifier} from '../../java'; + +/** A module a template's context binds, and whether the parse resolved it well enough to attribute. */ +export interface ContextBinding extends ModuleScopeBinding { + attributed: boolean; +} import {Parameter} from "./types"; /** @@ -90,11 +98,13 @@ export function setTemplateSourceFileCache(cache?: Map): // from one compile to the next; see `JavaScriptParser.parse`. const templateParsers: Map = new Map(); -function templateParser(workspaceDir?: string): JavaScriptParser { - const key = workspaceDir ?? ""; +function templateParser(workspaceDir?: string, types?: string[]): JavaScriptParser { + // `types` changes what the compiler loads, so parsers cannot be shared across differing sets. + // An empty list loads nothing where absence loads the defaults, so the two encode apart. + const key = `${workspaceDir ?? ""}::${JSON.stringify(types ?? null)}`; let parser = templateParsers.get(key); if (!parser) { - parser = new JavaScriptParser({relativeTo: workspaceDir, sourceFileCache: templateSourceFileCache}); + parser = new JavaScriptParser({relativeTo: workspaceDir, sourceFileCache: templateSourceFileCache, types}); templateParsers.set(key, parser); } return parser; @@ -115,7 +125,8 @@ class TemplateCache { templateString: string, captures: (Capture | Any)[], contextStatements: string[], - dependencies: Record + dependencies: Record, + types: string[] | undefined ): string { // Use the actual template string (with placeholders) as the primary key const templateKey = templateString; @@ -129,7 +140,7 @@ class TemplateCache { // Dependencies const depsKey = JSON.stringify(dependencies || {}); - return `${templateKey}::${capturesKey}::${contextKey}::${depsKey}`; + return `${templateKey}::${capturesKey}::${contextKey}::${depsKey}::${JSON.stringify(types ?? null)}`; } /** @@ -139,9 +150,10 @@ class TemplateCache { templateString: string, captures: (Capture | Any)[], contextStatements: string[], - dependencies: Record + dependencies: Record, + types?: string[] ): Promise { - const key = this.generateKey(templateString, captures, contextStatements, dependencies); + const key = this.generateKey(templateString, captures, contextStatements, dependencies, types); let cu = this.cache.get(key); if (cu) { @@ -163,7 +175,7 @@ class TemplateCache { // Parse and cache (workspace only needed during parsing) // Use templateSourceFileCache if configured for ~3.2x speedup on dependency file parsing - const parser = templateParser(workspaceDir); + const parser = templateParser(workspaceDir, types); const parseGenerator = parser.parse({text: fullTemplateString, sourcePath: 'template.tsx'}); cu = (await parseGenerator.next()).value as JS.CompilationUnit; @@ -208,34 +220,62 @@ export class TemplateEngine { * @param dependencies NPM dependencies for type attribution * @returns A Promise resolving to the extracted template AST */ - static async getTemplateTree( + /** The template parsed with its context, which is what gives its code types to attribute against. */ + private static async parseWithContext( templateParts: TemplateStringsArray, parameters: Parameter[], - contextStatements: string[] = [], - dependencies: Record = {} - ): Promise { - // Generate type preamble for captures/parameters with types + contextStatements: string[], + dependencies: Record, + types: string[] | undefined + ): Promise { + // A capture's declared type reaches the parse as a declaration, so it belongs with context. const preamble = TemplateEngine.parameterPreamble(parameters); - - // Build the template string with parameter placeholders const templateString = TemplateEngine.buildTemplateString(templateParts, parameters); - - // Add preamble to context statements (so they're skipped during extraction) const contextWithPreamble = preamble.length > 0 ? [...contextStatements, ...preamble] : contextStatements; + return templateCache.getOrParse(templateString, [], contextWithPreamble, dependencies, types); + } - // Use cache to get or parse the compilation unit - const cu = await templateCache.getOrParse( - templateString, - [], - contextWithPreamble, - dependencies - ); + /** + * The modules the template's context binds, for the caller to bind in the file being edited. + * An `import` or `require` states one; anything else — a `declare`, a helper signature — types + * the template without asking for a binding. + */ + static async getContextBindings( + templateParts: TemplateStringsArray, + parameters: Parameter[], + contextStatements: string[] = [], + dependencies: Record = {}, + types?: string[] + ): Promise { + const cu = await TemplateEngine.parseWithContext(templateParts, parameters, contextStatements, dependencies, types); + // The template's own code is the last statement, so everything ahead of it is context. + const context = {...cu, statements: cu.statements.slice(0, -1)}; + const attributed = new Set(); + walk(context.statements, node => { + if (isIdentifier(node) && (node.type !== undefined || node.fieldType !== undefined)) { + attributed.add(node.simpleName); + } + return true; + }); + return moduleScopeBindings(context) + .filter(b => b.module !== undefined) + .map(b => ({...b, attributed: attributed.has(b.name)})); + } + + static async getTemplateTree( + templateParts: TemplateStringsArray, + parameters: Parameter[], + contextStatements: string[] = [], + dependencies: Record = {}, + types?: string[] + ): Promise { + const cu = await TemplateEngine.parseWithContext(templateParts, parameters, contextStatements, dependencies, types); // Check if there are any statements if (!cu.statements || cu.statements.length === 0) { - throw new Error(`Failed to parse template code (no statements):\n${templateString}`); + throw new Error(`Failed to parse template code (no statements):\n${TemplateEngine.buildTemplateString(templateParts, parameters)}`); } // The template code is always the last statement (after context + preamble) diff --git a/rewrite-javascript/rewrite/src/javascript/templating/index.ts b/rewrite-javascript/rewrite/src/javascript/templating/index.ts index 376c751d2a..38ba5e0763 100644 --- a/rewrite-javascript/rewrite/src/javascript/templating/index.ts +++ b/rewrite-javascript/rewrite/src/javascript/templating/index.ts @@ -25,7 +25,6 @@ export type { MatchOptions, TemplateParameter, TemplateOptions, - ModuleBinding, RewriteRule, TryOnOptions, RewriteConfig, diff --git a/rewrite-javascript/rewrite/src/javascript/templating/rewrite.ts b/rewrite-javascript/rewrite/src/javascript/templating/rewrite.ts index 56ed9f6fe8..5cd20ec4ce 100644 --- a/rewrite-javascript/rewrite/src/javascript/templating/rewrite.ts +++ b/rewrite-javascript/rewrite/src/javascript/templating/rewrite.ts @@ -58,7 +58,8 @@ class RewriteRuleImpl implements RewriteRule { let result: J | undefined; const template = typeof this.after === 'function' ? this.after(match) : this.after; - const bindings = options?.bindings ?? (options?.visitor && template.resolveBindings(options.visitor)); + const bindings = options?.bindings ?? + (options?.visitor ? await template.resolveBindings(options.visitor) : undefined); result = await template.apply(node, cursor, { values: match, format: this.format, bindings: bindings || undefined }); diff --git a/rewrite-javascript/rewrite/src/javascript/templating/template.ts b/rewrite-javascript/rewrite/src/javascript/templating/template.ts index 378f6617a0..6bf1c3a866 100644 --- a/rewrite-javascript/rewrite/src/javascript/templating/template.ts +++ b/rewrite-javascript/rewrite/src/javascript/templating/template.ts @@ -16,8 +16,8 @@ import {Cursor, Tree} from '../..'; import {J} from '../../java'; import {ApplyOptions, Parameter, TemplateOptions, TemplateParameter} from './types'; -import {bindingContextStatement, isResolvable} from './bindings'; import {maybeBind} from '../binding'; +import {ContextBinding} from './engine'; import {JavaScriptVisitor} from '../visitor'; import {MatchResult} from './pattern'; import {generateCacheKey, globalAstCache, WRAPPERS_MAP_SYMBOL} from './utils'; @@ -249,11 +249,7 @@ export class Template { // Generate cache key for global lookup // For raw() parameters, we need to include their code values in the key // since they're spliced at construction time, not application time - const contextStatements = [ - ...(this.options.context || this.options.imports || []), - ...Object.entries(this.options.bindings ?? {}) - .map(([name, b]) => bindingContextStatement(name, b, this.options.dependencies ?? {})) - ]; + const contextStatements = this.options.context || this.options.imports || []; const parametersKey = this.parameters.map((p, i) => { const value = p.value; // Include raw code values in the cache key using the symbol @@ -267,7 +263,8 @@ export class Template { parametersKey, // As in Pattern.getAstPattern: a parameter's type reaches the parse as a declaration [...contextStatements, ...TemplateEngine.parameterPreamble(this.parameters)], - this.options.dependencies || {} + this.options.dependencies || {}, + this.options.types ); // Level 2: Global cache (fast path - shared with Pattern) @@ -282,7 +279,8 @@ export class Template { this.templateParts, this.parameters, contextStatements, - this.options.dependencies || {} + this.options.dependencies || {}, + this.options.types ) as JS.CompilationUnit; // Cache in both levels @@ -298,23 +296,38 @@ export class Template { * cannot resolve is bound whether or not the template goes on to reference it, so call this * where the template is known to apply — {@link RewriteRule.tryOn} does, once a pattern matched. */ - resolveBindings(visitor: JavaScriptVisitor): Record { + async resolveBindings(visitor: JavaScriptVisitor): Promise> { const resolved: Record = {}; - const dependencies = this.options.dependencies ?? {}; - for (const [name, binding] of Object.entries(this.options.bindings ?? {})) { - // Recognising the reference the template splices in takes attribution, which only the - // import form of a context statement carries. Without one there is nothing to look for. - const onlyIfReferenced = isResolvable(binding.module, dependencies); - const bound = maybeBind(visitor, {...binding, preferredName: name, onlyIfReferenced}); + for (const binding of await this.contextBindings()) { + // Recognising the reference the template splices in takes attribution, so a module the + // workspace could not resolve is bound whether or not the template turns out to use it. + const onlyIfReferenced = binding.attributed; + const bound = maybeBind(visitor, { + module: binding.module!, + member: binding.member, + typeOnly: binding.typeOnly, + preferredName: binding.name, + onlyIfReferenced + }); // An unresolved binding is left out rather than recorded as `undefined`, so `apply()`'s // own "applied without a local name" check catches it, same as a caller-omitted one. if (bound !== undefined) { - resolved[name] = bound; + resolved[binding.name] = bound; } } return resolved; } + /** What this template's context statements bind, which is what it needs bound in the target file. */ + private contextBindings(): Promise { + return TemplateEngine.getContextBindings( + this.templateParts, this.parameters, + this.options.context || this.options.imports || [], + this.options.dependencies || {}, + this.options.types + ); + } + /** * Applies this template and returns the resulting tree. * @@ -377,17 +390,20 @@ export class Template { } } - const declared = this.options.bindings ?? {}; const renames: Record = {}; const modules: Record = {}; - for (const [name, binding] of Object.entries(declared)) { - const bound = options?.bindings?.[name]; - if (bound === undefined) { - throw new Error(`Template declares a binding for '${name}' but was applied without a local name for it. ` + - `Pass bindings: template.resolveBindings(visitor) to apply().`); + // Supplying names is what asks for the context's modules to be bound in the file being + // edited; without them a context import only types the template. + if (options?.bindings !== undefined) { + for (const binding of await this.contextBindings()) { + const bound = options.bindings[binding.name]; + if (bound === undefined) { + throw new Error(`Template binds '${binding.name}' in its context but was applied without a local name for it. ` + + `Pass bindings: await template.resolveBindings(visitor) to apply().`); + } + renames[binding.name] = bound; + modules[binding.name] = binding.module!; } - renames[name] = bound; - modules[name] = binding.module; } // Use instance-level cache to get the template tree diff --git a/rewrite-javascript/rewrite/src/javascript/templating/types.ts b/rewrite-javascript/rewrite/src/javascript/templating/types.ts index b0e3729c1b..e8c2382363 100644 --- a/rewrite-javascript/rewrite/src/javascript/templating/types.ts +++ b/rewrite-javascript/rewrite/src/javascript/templating/types.ts @@ -485,27 +485,15 @@ export interface TemplateOptions { dependencies?: Record; /** - * Modules the template's code refers to, keyed by the identifier its source uses for each. - * The key is a preferred name: {@link Template.resolveBindings} deconflicts it against the - * file, and applying the template rewrites the template's references to whatever it settled on. + * Type packages to load whose declarations nothing imports. TypeScript reads `@types/*` on its + * own and everything else only when named here, so a package declaring its modules ambiently — + * rather than at a path matching the specifier — resolves only with this set. * - * @example - * ```typescript - * template`Theming.setTheme(${capture('theme')})` - * .configure({bindings: {Theming: {module: 'sap/ui/core/Theming', member: 'default'}}}) - * ``` + * @example `{dependencies: {'@sapui5/types': '^1.120.0'}, types: ['@sapui5/types']}` */ - bindings?: Record; + types?: string[]; } -/** - * A module a template's code depends on. The local name comes from the key it is declared under, - * and {@link Template.resolveBindings} settles the rest. `member` and `typeOnly` shape the import - * it creates, so a caller passing {@link ApplyOptions.bindings} of its own reads neither. - */ -export type ModuleBinding = Omit & { module: string }; - /** * Options for template application. */ diff --git a/rewrite-javascript/rewrite/src/javascript/templating/utils.ts b/rewrite-javascript/rewrite/src/javascript/templating/utils.ts index 334cd0916a..9cd7512d20 100644 --- a/rewrite-javascript/rewrite/src/javascript/templating/utils.ts +++ b/rewrite-javascript/rewrite/src/javascript/templating/utils.ts @@ -107,13 +107,15 @@ export function generateCacheKey( templateParts: string[] | TemplateStringsArray, itemsKey: string, contextStatements: string[], - dependencies: Record + dependencies: Record, + types?: string[] ): string { return [ Array.from(templateParts).join('|'), itemsKey, contextStatements.join(';'), - JSON.stringify(dependencies) + JSON.stringify(dependencies), + JSON.stringify(types ?? null) ].join('::'); } diff --git a/rewrite-javascript/rewrite/test/javascript/templating/dependencies.test.ts b/rewrite-javascript/rewrite/test/javascript/templating/dependencies.test.ts index 50926781e8..9705cb4981 100644 --- a/rewrite-javascript/rewrite/test/javascript/templating/dependencies.test.ts +++ b/rewrite-javascript/rewrite/test/javascript/templating/dependencies.test.ts @@ -29,7 +29,7 @@ import { typescript } from "../../../src/javascript"; import {DependencyWorkspace} from "../../../src/javascript/dependency-workspace"; -import {J} from "../../../src/java"; +import {J, Type} from "../../../src/java"; import {fromVisitor, RecipeSpec} from "../../../src/test"; import * as path from "path"; import * as os from "os"; @@ -76,6 +76,50 @@ describe('template dependencies integration', () => { expect(foundMatch).toBe(true); }, 60000); + test('`types` decides which declarations the template parse loads', async () => { + // `process` is a global `@types/node` declares, reachable only through automatic type + // inclusion — unlike a module specifier, which resolves by path whatever this option says. + // An explicit list replaces the default rather than extending it, so `[]` loads nothing. + const processDeclaringTypeWith = async (types: string[]) => { + const tmpl = template`process.cwd()`.configure({ + dependencies: {'@types/node': '^20.0.0'}, + types + }); + + const parser = new JavaScriptParser(); + const parseGen = parser.parse({text: `const x = 1;`, sourcePath: 'test.ts'}); + const cu = (await parseGen.next()).value; + + let applied: J | undefined; + await (new class extends JavaScriptVisitor { + override async visitVariable(variable: any, _p: any): Promise { + applied ??= await tmpl.apply(variable, this.cursor, {values: new Map()}); + return variable; + } + }).visit(cu, undefined); + + let methodType: Type.Method | undefined; + await (new class extends JavaScriptVisitor { + override async visitMethodInvocation(method: J.MethodInvocation, _p: any): Promise { + if (method.name.simpleName === 'cwd') { + methodType ??= method.methodType; + } + return method; + } + }).visit(applied!, undefined); + // `name` is the callee's own spelling whether or not anything resolved, so the + // declaring type is what says the declarations were loaded. + const declaring = methodType?.declaringType; + return declaring && Type.isClass(declaring) + ? (declaring as Type.Class).fullyQualifiedName + : undefined; + }; + + expect(await processDeclaringTypeWith(['node'])).toBe('global.NodeJS.Process'); + + expect(await processDeclaringTypeWith([])).toBeUndefined(); + }, 120000); + test('template with dependencies generates AST with type attribution', async () => { // Create a template with dependencies const tmpl = template`v1()`.configure({ diff --git a/rewrite-javascript/rewrite/test/javascript/templating/template-bindings.test.ts b/rewrite-javascript/rewrite/test/javascript/templating/template-bindings.test.ts index 6b3e0fcd99..e88f86b14e 100644 --- a/rewrite-javascript/rewrite/test/javascript/templating/template-bindings.test.ts +++ b/rewrite-javascript/rewrite/test/javascript/templating/template-bindings.test.ts @@ -16,7 +16,6 @@ import {fromVisitor, RecipeSpec} from "../../../src/test"; import {capture, JavaScriptVisitor, pattern, rewrite, template, typescript} from "../../../src/javascript"; import {J} from "../../../src/java"; -import {bindingContextStatement} from "../../../src/javascript/templating/bindings"; describe('templates that declare module bindings', () => { const spec = new RecipeSpec(); @@ -35,7 +34,7 @@ describe('templates that declare module bindings', () => { test('the bound name is deconflicted and the template follows it', async () => { const arg = capture('arg'); spec.recipe = recipeApplying(template`Theming.setTheme(${arg})`.configure({ - bindings: {Theming: {module: 'sap/ui/core/Theming', member: 'default'}} + context: [`import Theming from 'sap/ui/core/Theming';`] }), arg); await spec.rewriteRun( @@ -50,7 +49,7 @@ describe('templates that declare module bindings', () => { test('an import already binding the module is reused, under the name it already has', async () => { const arg = capture('arg'); spec.recipe = recipeApplying(template`Theming.setTheme(${arg})`.configure({ - bindings: {Theming: {module: 'sap/ui/core/Theming', member: 'default'}} + context: [`import Theming from 'sap/ui/core/Theming';`] }), arg); await spec.rewriteRun( @@ -65,10 +64,10 @@ describe('templates that declare module bindings', () => { test('a template resolves every binding it declares', async () => { const arg = capture('arg'); spec.recipe = recipeApplying(template`new Locale(Localization.getLanguageTag(${arg}))`.configure({ - bindings: { - Locale: {module: 'sap/ui/core/Locale', member: 'default'}, - Localization: {module: 'sap/base/i18n/Localization', member: 'default'} - } + context: [ + `import Locale from 'sap/ui/core/Locale';`, + `import Localization from 'sap/base/i18n/Localization';` + ] }), arg); await spec.rewriteRun( @@ -83,7 +82,7 @@ describe('templates that declare module bindings', () => { test('a name in a naming position is not a reference to the binding', async () => { const arg = capture('arg'); spec.recipe = recipeApplying(template`Theming.setTheme(${arg}.Theming)`.configure({ - bindings: {Theming: {module: 'sap/ui/core/Theming', member: 'default'}} + context: [`import Theming from 'sap/ui/core/Theming';`] }), arg); await spec.rewriteRun( @@ -98,7 +97,7 @@ describe('templates that declare module bindings', () => { test('a binding called on its own is a reference to it, not a name it gives something', async () => { const arg = capture('arg'); spec.recipe = recipeApplying(template`merge(${arg}, {}).merge()`.configure({ - bindings: {merge: {module: 'sap/base/util/merge', member: 'default'}} + context: [`import merge from 'sap/base/util/merge';`] }), arg); await spec.rewriteRun( @@ -113,7 +112,7 @@ describe('templates that declare module bindings', () => { test('a rule that does not fire leaves the file\'s imports alone', async () => { const arg = capture('arg'); spec.recipe = recipeApplying(template`Theming.setTheme(${arg})`.configure({ - bindings: {Theming: {module: 'sap/ui/core/Theming', member: 'default'}} + context: [`import Theming from 'sap/ui/core/Theming';`] }), arg); await spec.rewriteRun( @@ -122,22 +121,22 @@ describe('templates that declare module bindings', () => { ); }); - test('a binding is parsed against an import only where the dependencies could resolve it', () => { - expect(bindingContextStatement('Theming', {module: 'sap/ui/core/Theming', member: 'default'}, {})) - .toBe('declare const Theming: any;'); - expect(bindingContextStatement('Theming', {module: 'sap/ui/core/Theming', member: 'default'}, {sap: '^1.0.0'})) - .toBe("import Theming from 'sap/ui/core/Theming';"); + test('a context statement that is not an import binds nothing', async () => { + const arg = capture('arg'); + spec.recipe = recipeApplying(template`helper(${arg})`.configure({ + context: [`declare function helper(x: string): void;`] + }), arg); - expect(bindingContextStatement('Props', {module: '@scope/pkg/props', member: 'Props', typeOnly: true}, {})) - .toBe('type Props = any;'); - expect(bindingContextStatement('Props', {module: '@scope/pkg/props', member: 'Props', typeOnly: true}, {'@scope/pkg': '^1.0.0'})) - .toBe("import type {Props} from '@scope/pkg/props';"); + await spec.rewriteRun( + //language=typescript + typescript(`applyTheme('dark');`, `helper('dark');`) + ); }); test('a declared binding left unresolved at apply is an error, not a silent wrong name', async () => { const arg = capture('arg'); const tmpl = template`Theming.setTheme(${arg})`.configure({ - bindings: {Theming: {module: 'sap/ui/core/Theming', member: 'default'}} + context: [`import Theming from 'sap/ui/core/Theming';`] }); const rule = rewrite(() => ({before: pattern`applyTheme(${arg})`, after: tmpl})); From 402cd6d195016882d74f73f92b2bd2e8194c90dc Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Fri, 28 Aug 2026 09:45:37 +0200 Subject: [PATCH 39/41] JavaScript: drop the AddImportOptions import ModuleBinding left behind --- rewrite-javascript/rewrite/src/javascript/templating/types.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/rewrite-javascript/rewrite/src/javascript/templating/types.ts b/rewrite-javascript/rewrite/src/javascript/templating/types.ts index e8c2382363..b31b765ab6 100644 --- a/rewrite-javascript/rewrite/src/javascript/templating/types.ts +++ b/rewrite-javascript/rewrite/src/javascript/templating/types.ts @@ -18,7 +18,6 @@ import {J, Type} from '../../java'; import type {Pattern} from "./pattern"; import type {Template} from "./template"; import type {CaptureValue, RawCode} from "./capture"; -import type {AddImportOptions} from "../add-import"; import type {JavaScriptVisitor} from "../visitor"; /** From 03f5e2c2af541bcf159a663c0d8fb2282b656d8e Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Fri, 28 Aug 2026 10:11:23 +0200 Subject: [PATCH 40/41] JavaScript: a rule refuses to apply a template whose bindings it cannot resolve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tryOn` given neither a visitor nor bindings applied a template that binds context modules anyway, splicing the context's own names in unbound: a file matching `applyTheme('dark')` came out as `Theming.setTheme('dark')` with no import for `Theming`. It reads as a working edit and is not one. The test named for this passed throughout, because it asserted `rejects.toThrow(/Theming/)` and the run failed for an unrelated reason — an unexpected change — whose message quoted the rewritten source. It now matches the error's own words rather than the payload, which is what let a wrong output masquerade as the right exception. `bindsModules` counts only the `import` and `require` statements in a template's context, so a `declare` or a helper signature never demands a visitor, and a template binding nothing keeps working with the two-argument `tryOn` the rest of the suite uses. An explicitly supplied but incomplete map still falls to `apply`'s per-name error. `PatternOptions` gains `types`, since a pattern matches on attribution and a module typed only ambiently cannot match without it. Under strict matching it decides the result outright; lenient matching, the default, succeeds either way, which is what makes it lenient. `Template.getTemplateTree` returns the statement `TemplateEngine` extracts, not a compilation unit, so it and `applyTemplateFromAst` say `J` rather than casting to one. --- .../src/javascript/templating/engine.ts | 8 +++-- .../src/javascript/templating/pattern.ts | 6 ++-- .../src/javascript/templating/rewrite.ts | 7 +++++ .../src/javascript/templating/template.ts | 15 ++++++--- .../src/javascript/templating/types.ts | 6 ++++ .../templating/dependencies.test.ts | 31 +++++++++++++++++++ .../templating/template-bindings.test.ts | 4 +-- 7 files changed, 65 insertions(+), 12 deletions(-) diff --git a/rewrite-javascript/rewrite/src/javascript/templating/engine.ts b/rewrite-javascript/rewrite/src/javascript/templating/engine.ts index 934ba56532..99cef891c1 100644 --- a/rewrite-javascript/rewrite/src/javascript/templating/engine.ts +++ b/rewrite-javascript/rewrite/src/javascript/templating/engine.ts @@ -303,7 +303,7 @@ export class TemplateEngine { * @returns A Promise resolving to the generated AST node */ static async applyTemplateFromAst( - ast: JS.CompilationUnit, + ast: J, parameters: Parameter[], cursor: Cursor, coordinates: JavaCoordinates, @@ -530,7 +530,8 @@ export class TemplateEngine { templateParts: TemplateStringsArray, captures: (Capture | Any | RawCode)[], contextStatements: string[] = [], - dependencies: Record = {} + dependencies: Record = {}, + types?: string[] ): Promise { const preamble = TemplateEngine.capturePreamble(captures); @@ -571,7 +572,8 @@ export class TemplateEngine { templateString, actualCaptures, contextWithPreamble, - dependencies + dependencies, + types ); // Check if there are any statements diff --git a/rewrite-javascript/rewrite/src/javascript/templating/pattern.ts b/rewrite-javascript/rewrite/src/javascript/templating/pattern.ts index 37f2f0f760..16225f6f15 100644 --- a/rewrite-javascript/rewrite/src/javascript/templating/pattern.ts +++ b/rewrite-javascript/rewrite/src/javascript/templating/pattern.ts @@ -236,7 +236,8 @@ export class Pattern { // A capture's type reaches the parse as a declaration, so it shapes the tree the same // way an explicit context statement does and belongs in the key alongside one. [...contextStatements, ...TemplateEngine.capturePreamble(this.captures)], - this._options.dependencies || {} + this._options.dependencies || {}, + this._options.types ); // Level 2: Global cache (fast path - shared with Template) @@ -247,7 +248,8 @@ export class Pattern { this.templateParts, this.captures, contextStatements, - this._options.dependencies || {} + this._options.dependencies || {}, + this._options.types ); globalAstCache.set(cacheKey, tree); } diff --git a/rewrite-javascript/rewrite/src/javascript/templating/rewrite.ts b/rewrite-javascript/rewrite/src/javascript/templating/rewrite.ts index 5cd20ec4ce..b555de0dc1 100644 --- a/rewrite-javascript/rewrite/src/javascript/templating/rewrite.ts +++ b/rewrite-javascript/rewrite/src/javascript/templating/rewrite.ts @@ -60,6 +60,13 @@ class RewriteRuleImpl implements RewriteRule { const template = typeof this.after === 'function' ? this.after(match) : this.after; const bindings = options?.bindings ?? (options?.visitor ? await template.resolveBindings(options.visitor) : undefined); + // Applying without them would splice the context's own names in unbound, which + // reads as a working edit and is not one. + if (bindings === undefined && await template.bindsModules()) { + throw new Error( + "Template binds modules in its context, so applying it needs their local names. " + + "Pass {visitor: this} to tryOn, or bindings you resolved yourself."); + } result = await template.apply(node, cursor, { values: match, format: this.format, bindings: bindings || undefined }); diff --git a/rewrite-javascript/rewrite/src/javascript/templating/template.ts b/rewrite-javascript/rewrite/src/javascript/templating/template.ts index 6bf1c3a866..9e2ef27329 100644 --- a/rewrite-javascript/rewrite/src/javascript/templating/template.ts +++ b/rewrite-javascript/rewrite/src/javascript/templating/template.ts @@ -240,10 +240,10 @@ export class Template { * @returns The cached or newly computed template tree * @internal */ - private async getTemplateTree(): Promise { + private async getTemplateTree(): Promise { // Level 1: Instance cache (fastest path) if (this._cachedTemplate) { - return this._cachedTemplate as JS.CompilationUnit; + return this._cachedTemplate; } // Generate cache key for global lookup @@ -270,8 +270,8 @@ export class Template { // Level 2: Global cache (fast path - shared with Pattern) const cached = globalAstCache.get(cacheKey); if (cached) { - this._cachedTemplate = cached as JS.CompilationUnit; - return cached as JS.CompilationUnit; + this._cachedTemplate = cached; + return cached; } // Level 3: Compute via TemplateEngine (slow path) @@ -281,7 +281,7 @@ export class Template { contextStatements, this.options.dependencies || {}, this.options.types - ) as JS.CompilationUnit; + ); // Cache in both levels globalAstCache.set(cacheKey, result); @@ -318,6 +318,11 @@ export class Template { return resolved; } + /** Whether the context binds a module, which applying this template therefore has to resolve. */ + async bindsModules(): Promise { + return (await this.contextBindings()).length > 0; + } + /** What this template's context statements bind, which is what it needs bound in the target file. */ private contextBindings(): Promise { return TemplateEngine.getContextBindings( diff --git a/rewrite-javascript/rewrite/src/javascript/templating/types.ts b/rewrite-javascript/rewrite/src/javascript/templating/types.ts index b31b765ab6..0fe90d6177 100644 --- a/rewrite-javascript/rewrite/src/javascript/templating/types.ts +++ b/rewrite-javascript/rewrite/src/javascript/templating/types.ts @@ -349,6 +349,12 @@ export interface PatternOptions { */ dependencies?: Record; + /** + * Type packages to load whose declarations nothing imports, as {@link TemplateOptions.types}. + * A pattern matches on attribution, so a module typed only ambiently needs this to match. + */ + types?: string[]; + /** * When true, allows patterns without type annotations to match code with type annotations. * This enables more flexible pattern matching during development or when full type attribution diff --git a/rewrite-javascript/rewrite/test/javascript/templating/dependencies.test.ts b/rewrite-javascript/rewrite/test/javascript/templating/dependencies.test.ts index 9705cb4981..4363249c3c 100644 --- a/rewrite-javascript/rewrite/test/javascript/templating/dependencies.test.ts +++ b/rewrite-javascript/rewrite/test/javascript/templating/dependencies.test.ts @@ -76,6 +76,37 @@ describe('template dependencies integration', () => { expect(foundMatch).toBe(true); }, 60000); + test('`types` decides whether a pattern matches, where matching is strict', async () => { + // A pattern matches on attribution, so the declarations it was parsed against decide it. + // Lenient matching — the default — succeeds either way, which is what makes it lenient. + const workspaceDir = await DependencyWorkspace.getOrCreateWorkspace({dependencies: {'@types/node': '^20.0.0'}}); + const parser = new JavaScriptParser({relativeTo: workspaceDir, types: ['node']}); + const parseGen = parser.parse({text: `process.cwd();`, sourcePath: 'test.ts'}); + const cu = (await parseGen.next()).value; + + const matchesWith = async (types: string[]) => { + const pat = pattern`process.cwd()`.configure({ + dependencies: {'@types/node': '^20.0.0'}, + types, + lenientTypeMatching: false + }); + let matched = false; + await (new class extends JavaScriptVisitor { + override async visitMethodInvocation(method: J.MethodInvocation, _p: any): Promise { + if (method.name.simpleName === 'cwd' && await pat.match(method, this.cursor)) { + matched = true; + } + return method; + } + }).visit(cu, undefined); + return matched; + }; + + expect(await matchesWith(['node'])).toBe(true); + + expect(await matchesWith([])).toBe(false); + }, 120000); + test('`types` decides which declarations the template parse loads', async () => { // `process` is a global `@types/node` declares, reachable only through automatic type // inclusion — unlike a module specifier, which resolves by path whatever this option says. diff --git a/rewrite-javascript/rewrite/test/javascript/templating/template-bindings.test.ts b/rewrite-javascript/rewrite/test/javascript/templating/template-bindings.test.ts index e88f86b14e..fa21a07f61 100644 --- a/rewrite-javascript/rewrite/test/javascript/templating/template-bindings.test.ts +++ b/rewrite-javascript/rewrite/test/javascript/templating/template-bindings.test.ts @@ -133,7 +133,7 @@ describe('templates that declare module bindings', () => { ); }); - test('a declared binding left unresolved at apply is an error, not a silent wrong name', async () => { + test('a rule given no way to resolve its bindings is an error, not a silent wrong name', async () => { const arg = capture('arg'); const tmpl = template`Theming.setTheme(${arg})`.configure({ context: [`import Theming from 'sap/ui/core/Theming';`] @@ -150,6 +150,6 @@ describe('templates that declare module bindings', () => { await expect(spec.rewriteRun( //language=typescript typescript(`applyTheme('dark');`) - )).rejects.toThrow(/Theming/); + )).rejects.toThrow(/needs their local names/); }); }); From b08f132ee21fe4c8c8d3a21cbc386483b6418407 Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Fri, 28 Aug 2026 10:43:02 +0200 Subject: [PATCH 41/41] JavaScript: fix the binding defects an adversarial review reproduced Seven correctness findings, each with a test that fails without its fix. `removeNewlyUnusedAmdBindings` dropped a factory parameter from a block written `define(factory)`, where the parameters pair with `require`, `exports` and `module` supplied by the loader rather than with a dependency list. `bindAmd` and `withDependency` both hold the count invariant; the sweeper now does too. A type-only import answered a request for a value. The name it binds erases, so the reuse emitted a reference to nothing. `ModuleObjectBinding` carries `typeOnly` and the whole-module lookup compares it, as `bindImport`'s own lookup already did. `isCommonJs` read any file containing a `require` declaration as CommonJS without consulting `hasEsmSyntax`, so an ES module that also called `require` was refused an import it could legally take. `export` settles the module system whatever else the file calls. `maybeRebind` queued its AMD visitor without dedup, alone among the three operations, so a recipe asking once per matched reference failed the run when the second visitor found the dependency already moved. `bindAmd` deconflicted against the names visible at the cursor, but a factory parameter is in scope across the whole factory and the queue hands its name to later requests at cursors inside it. It now clears every name the factory declares, which is what the ESM lane's `namesDeclaredIn` does and for the reason stated there. `namesDeclaredWithin` generalises that walk to a subtree; deconfliction no longer reads the cursor at all, leaving lane detection its only use. `alias` was ignored on both lanes, against its documented "taken verbatim, never deconflicted": AMD bound the derived name, and ESM reuse handed back another name for the same module. A pinned alias is now bound as given or refused. Moving the only named member out of `import D, {a} from "m"` left `{}` behind, which prints. Also: empty-string module names no longer leak out of `moduleOf`/`bindingOf` or select a non-literal dependency; the deprecation note and the "applied without a local name" error describe what the code does; `MaybeBindOptions` says which options the AMD lane cannot honour; a template's context bindings are derived once rather than per application; and the refusal matrix moves from an 8-line JSDoc into CLAUDE.md. --- rewrite-javascript/rewrite/CLAUDE.md | 31 ++++++ .../rewrite/src/javascript/add-import.ts | 5 + .../rewrite/src/javascript/amd.ts | 27 ++++-- .../rewrite/src/javascript/binding.ts | 78 ++++++++------- .../rewrite/src/javascript/scope.ts | 13 ++- .../src/javascript/templating/template.ts | 11 ++- .../rewrite/test/javascript/binding.test.ts | 96 +++++++++++++++++++ 7 files changed, 216 insertions(+), 45 deletions(-) diff --git a/rewrite-javascript/rewrite/CLAUDE.md b/rewrite-javascript/rewrite/CLAUDE.md index 53ad18f681..011e1c406c 100644 --- a/rewrite-javascript/rewrite/CLAUDE.md +++ b/rewrite-javascript/rewrite/CLAUDE.md @@ -209,6 +209,37 @@ before every test. A consumer of the published package opts in by calling `RecipeSpec.trackSuiteConfiguration()` from its own vitest `setupFiles`; without that call no spec is registered and nothing is reset. +## JavaScript module bindings + +`maybeBind`, `maybeUnbind` and `maybeRebind` (`src/javascript/binding.ts`) give a recipe a local +name for a module whether the file uses ES imports, `require`, or an AMD `define` block. The lane +is decided from the cursor, so one call serves all three. + +AMD pairs the dependency array with the factory parameter list *by position*, and a parameter can +only be appended at the end. Nothing in the LST enforces that pairing, and a mistake parses and +prints plausibly while binding later modules to the wrong names — so every edit keeps the two +lists index-aligned, and an operation that cannot is refused rather than guessed. + +### When `maybeBind` returns `undefined` + +- the block's dependency and parameter counts already disagree, in either direction +- a `member` is requested on the AMD lane, which binds whole modules only +- the file binds its modules with `require` and one would have to be created (`ImportStyle.CommonJS` + has no add path); reuse of an existing `require` is always allowed +- no legal identifier can be derived from the module's last path segment and no `preferredName` + or `alias` was given — `lodash-es`, `node:fs`, `@scope/my-lib`, `a/class` +- a pinned `alias` cannot be bound verbatim, since deconflicting it would leave code the caller + already emitted unbound +- there is no reachable compilation unit + +### When `maybeRebind` returns `undefined` + +The four above that still apply, plus: nothing binds `from`; `from` or `to` names a member on the +AMD lane; or the two differ in default/namespace/named shape while `from`'s statement binds nothing +else. That last one is a layering boundary rather than an oversight — the only edit available in +place is a rewrite of the existing clause, and changing shape needs whole-statement replacement +with the header-preserving prefix transfer that `RemoveImport` does over the statement list. + ## RPC Sender/Receiver Each language module has `rpc.ts` with a Sender (visit tree → serialize to queue) and Receiver (read queue → reconstruct tree). These must stay aligned with each other AND with the Java equivalents. Any mismatch causes deadlocks or corrupted trees. diff --git a/rewrite-javascript/rewrite/src/javascript/add-import.ts b/rewrite-javascript/rewrite/src/javascript/add-import.ts index 378ccd5213..27d79064e9 100644 --- a/rewrite-javascript/rewrite/src/javascript/add-import.ts +++ b/rewrite-javascript/rewrite/src/javascript/add-import.ts @@ -1745,6 +1745,11 @@ function removeBinding(jsImport: JS.Import, member: string | undefined): JS.Impo kept.push({...entry, element: formatter.processKept(entry.element)}); } } + if (kept.length === 0) { + // An emptied brace list still prints, as `import D, {} from "m"`, so it goes with its + // last member; the caller drops the whole statement when no default remains either. + return {...jsImport, importClause: {...importClause, namedBindings: undefined}}; + } const updatedNamedImports: JS.NamedImports = {...namedImports, elements: {...namedImports.elements, elements: kept}}; return {...jsImport, importClause: {...importClause, namedBindings: updatedNamedImports}}; } diff --git a/rewrite-javascript/rewrite/src/javascript/amd.ts b/rewrite-javascript/rewrite/src/javascript/amd.ts index 19910ab96b..da7a4ee105 100644 --- a/rewrite-javascript/rewrite/src/javascript/amd.ts +++ b/rewrite-javascript/rewrite/src/javascript/amd.ts @@ -29,7 +29,7 @@ import { TrailingComma } from "../java"; import {JS} from "./tree"; -import {cursorOf, deconflict} from "./scope"; +import {cursorOf, deconflict, namesDeclaredWithin} from "./scope"; import {JavaScriptVisitor} from "./visitor"; import {ExecutionContext} from "../execution"; @@ -611,13 +611,13 @@ export function bindAmd( amd: {call: J.MethodInvocation, block: AmdBlock}, module: string, preferredName: string | undefined, - namesInScope: ReadonlySet, - callees: readonly string[] + callees: readonly string[], + pinned: boolean = false ): string | undefined { const modules = dependencyNames(amd.block); const bindings = parameterNames(amd.block); - const declared = modules.indexOf(module); + const declared = module === "" ? -1 : modules.indexOf(module); if (declared >= 0) { return bindings[declared]; } @@ -639,8 +639,16 @@ export function bindAmd( if (preferredName === undefined && derivedBindingName(module) === undefined) { return undefined; } - const taken = [...bindings, ...namesInScope, ...queued.map(v => v.binding)]; - const binding = deconflict(preferredName ?? derivedBindingName(module)!, candidate => taken.includes(candidate)); + // A parameter is in scope across the whole factory, and the queue hands this name to later + // requests at cursors inside it, so every name the factory declares is one it has to clear. + const taken = [...bindings, ...namesDeclaredWithin(amd.block.factory), ...queued.map(v => v.binding)]; + const requested = preferredName ?? derivedBindingName(module)!; + const binding = deconflict(requested, candidate => taken.includes(candidate)); + if (pinned && binding !== requested) { + // An alias is bound verbatim or not at all, since the caller may already have emitted + // code naming it, and a deconflicted spelling would leave that unbound. + return undefined; + } visitor.afterVisit.push(new AddAmdDependency(amd.call.id, module, binding, callees)); return binding; } @@ -784,7 +792,7 @@ export class RebindAmdDependency

extends JavaScriptVisitor

{ return visited; } this.applied = true; - const index = dependencyNames(block).indexOf(this.fromModule); + const index = this.fromModule === "" ? -1 : dependencyNames(block).indexOf(this.fromModule); if (index < 0) { throw new Error( `AMD block ${this.blockId} no longer has dependency '${this.fromModule}' to rebind to '${this.toModule}'`); @@ -865,6 +873,11 @@ export async function removeNewlyUnusedAmdBindings( if (usedBefore === undefined || block === undefined) { return call; } + // A block written `define(factory)` takes `require`, `exports` and `module` from the + // loader, so a parameter there is positional against arguments no dependency list names. + if (block.dependenciesIndex < 0 || parameterNames(block).length !== elementsOf(block).length) { + return call; + } // withoutDependencyAt only edits the dependency array and parameter list, never the // body, so which names it references can't change between removals. const usedNow = await namesUsed(block, true); diff --git a/rewrite-javascript/rewrite/src/javascript/binding.ts b/rewrite-javascript/rewrite/src/javascript/binding.ts index ba617f1595..7caa9311d9 100644 --- a/rewrite-javascript/rewrite/src/javascript/binding.ts +++ b/rewrite-javascript/rewrite/src/javascript/binding.ts @@ -16,7 +16,7 @@ import {J} from "../java"; import {JS} from "./tree"; import {JavaScriptVisitor} from "./visitor"; -import {compilationUnitOf, cursorOf, declarationsOf, scopeOf, walk} from "./scope"; +import {compilationUnitOf, cursorOf, declarationsOf, walk} from "./scope"; import {AddImportOptions, bindImport, existingImportBinding, memberName, moduleNameOf, RebindImport, requiredModuleOf} from "./add-import"; import {RemoveImport} from "./remove-import"; import { @@ -24,7 +24,12 @@ import { parameterNames, RebindAmdDependency, RemoveAmdDependency } from "./amd"; -/** A bare string is shorthand for `{module}`, the overwhelmingly common call with nothing else to configure. */ +/** + * A bare string is shorthand for `{module}`, the overwhelmingly common call with nothing else to + * configure. A factory parameter binds a whole module under a name and nothing else, so on the AMD + * lane `member`, `typeOnly`, `sideEffectOnly`, `onlyIfReferenced`, `quoteStyle` and `style` do not + * apply: the first three refuse, and the rest have nothing to shape. + */ export interface MaybeBindOptions extends AddImportOptions { /** Callees that introduce an AMD block. UI5 writes `sap.ui.define`, RequireJS and Dojo `define`. */ amdCallee?: string | readonly string[]; @@ -85,10 +90,12 @@ export function moduleBindings( moduleSystem: "amd", moduleOf: localName => { const index = bindings.indexOf(localName); - return index < 0 ? undefined : modules[index]; + // `dependencyNames` pads a non-literal element with "" to hold its position; that + // filler names no module, so it answers neither lookup. + return index < 0 || modules[index] === "" ? undefined : modules[index]; }, bindingOf: module => { - const index = modules.indexOf(module); + const index = module === "" ? -1 : modules.indexOf(module); return index < 0 ? undefined : bindings[index]; } }; @@ -155,6 +162,9 @@ interface ModuleObjectBinding { * `member: "*"`, which is what a module exporting no default is bound by. */ shape: "default" | "namespace" | "require"; + + /** A type-only import erases, so the name it binds stands for no value at runtime. */ + typeOnly: boolean; } /** Whether the file binds its modules with `require`, which decides whether a create is possible. */ @@ -168,16 +178,11 @@ function isCommonJs(cu: JS.CompilationUnit): boolean { if (cu.sourcePath.endsWith(".mjs") || cu.sourcePath.endsWith(".mts")) { return false; } - let requires = false; - for (const stmt of cu.statements) { - if (stmt.element?.kind === JS.Kind.Import) { - return false; - } - if (declarationsOf(stmt.element).some(d => requiredModule(d) !== undefined)) { - requires = true; - } + if (hasEsmSyntax(cu)) { + return false; } - return requires; + return cu.statements.some(stmt => + declarationsOf(stmt.element).some(d => requiredModule(d) !== undefined)); } /** The module a `const X = require("m")` declaration names, for the one variable it declares. */ @@ -226,7 +231,7 @@ function wholeModuleBindingsVia( const module = moduleOf(declaration); const name = declaration.variables[0]?.element?.name; if (module !== undefined && name?.kind === J.Kind.Identifier) { - bindings.push({name: (name as J.Identifier).simpleName, module, shape}); + bindings.push({name: (name as J.Identifier).simpleName, module, shape, typeOnly: false}); } } } @@ -251,8 +256,9 @@ function moduleObjectBindings(cu: JS.CompilationUnit): ModuleObjectBinding[] { continue; } const clause = jsImport.importClause; + const typeOnly = clause?.typeOnly ?? false; if (clause?.name?.element?.kind === J.Kind.Identifier) { - bindings.push({name: (clause.name.element as J.Identifier).simpleName, module, shape: "default"}); + bindings.push({name: (clause.name.element as J.Identifier).simpleName, module, shape: "default", typeOnly}); } // `namedBindings` is a `JS.Alias` only for `import * as X from "m"`; a renamed // named import (`{a as b}`) nests its alias inside `NamedImports` instead. @@ -260,7 +266,7 @@ function moduleObjectBindings(cu: JS.CompilationUnit): ModuleObjectBinding[] { if (named?.kind === JS.Kind.Alias) { const alias = (named as JS.Alias).alias; if (alias?.kind === J.Kind.Identifier) { - bindings.push({name: (alias as J.Identifier).simpleName, module, shape: "namespace"}); + bindings.push({name: (alias as J.Identifier).simpleName, module, shape: "namespace", typeOnly}); } } } @@ -269,9 +275,13 @@ function moduleObjectBindings(cu: JS.CompilationUnit): ModuleObjectBinding[] { return bindings; } -/** Whether `binding`'s shape satisfies a whole-module request for the namespace form when `wantsNamespace`. */ -function answersWholeModuleRequest(binding: ModuleObjectBinding, wantsNamespace: boolean): boolean { - return binding.shape === "require" || binding.shape === (wantsNamespace ? "namespace" : "default"); +/** + * Whether `binding` answers a whole-module request: the namespace form when `wantsNamespace`, and + * never a type-only import for a value, which erases and would leave the reference unbound. + */ +function answersWholeModuleRequest(binding: ModuleObjectBinding, wantsNamespace: boolean, typeOnly: boolean): boolean { + return binding.typeOnly === typeOnly && + (binding.shape === "require" || binding.shape === (wantsNamespace ? "namespace" : "default")); } /** @@ -296,15 +306,18 @@ export function maybeBind( // Nor can a factory parameter load a module without binding it to a name. return undefined; } - const namesInScope = scopeOf(cursorOf(visitor)!).names(); - return bindAmd(visitor, amd, module, options.preferredName, namesInScope, calleesOf(options)); + return bindAmd(visitor, amd, module, options.alias ?? options.preferredName, + calleesOf(options), options.alias !== undefined); } const cu = compilationUnitOf(visitor); const isWholeModule = !options.sideEffectOnly && (key === undefined || key === "*"); if (isWholeModule) { const bound = cu && moduleObjectBindings(cu).find(b => - b.module === module && answersWholeModuleRequest(b, key === "*")); + b.module === module && answersWholeModuleRequest(b, key === "*", options.typeOnly ?? false) && + // A pinned alias asks for a binding of that name, so another name for the same + // module does not answer it; `bindImport`'s own lookup applies the same rule. + (options.alias === undefined || b.name === options.alias)); if (bound !== undefined) { return bound.name; } @@ -362,13 +375,8 @@ function bindingShape(member: string | undefined): "default" | "namespace" | "na /** * Moves the binding for `from` to `to`, keeping the local name it already had — the primitive - * behind a member rename or a module move, so a caller never has to thread that name through a - * separate unbind and bind itself. Refuses, changing nothing, where nothing binds `from`, where - * `from` or `to` names a member on the AMD lane (a factory parameter binds only a whole module), - * where completing the move on the ESM lane would have to gain an import into a CommonJS file, or - * where `from` and `to` differ in default/namespace/named shape and `from`'s statement binds - * nothing else — the only edit then available is in place, and an in-place edit cannot restructure - * the clause it's editing. + * behind a member rename or a module move. Returns `undefined`, changing nothing, where the move + * is not safely expressible; the refusals are listed in CLAUDE.md: JavaScript module bindings. */ export function maybeRebind(visitor: JavaScriptVisitor, options: MaybeRebindOptions): string | undefined { const amd = enclosingAmdBlock(visitor, options); @@ -381,7 +389,12 @@ export function maybeRebind(visitor: JavaScriptVisitor, options: MaybeRebin if (binding === undefined) { return undefined; } - visitor.afterVisit.push(new RebindAmdDependency(amd.call.id, options.from.module, options.to.module, calleesOf(options))); + const callees = calleesOf(options); + const from = options.from.module; + if (!(visitor.afterVisit || []).some(v => v instanceof RebindAmdDependency && + v.blockId === amd.call.id && v.fromModule === from && sameCallees(v.callees, callees))) { + visitor.afterVisit.push(new RebindAmdDependency(amd.call.id, from, options.to.module, callees)); + } return binding; } @@ -412,8 +425,9 @@ export function maybeRemoveImport(visitor: JavaScriptVisitor, module: strin } /** - * @deprecated Use {@link maybeBind} instead — this is a rename, not a behaviour change, beyond - * `maybeBind` additionally binding through an AMD factory parameter where this only ever imports. + * @deprecated Use {@link maybeBind} instead. Beyond binding through an AMD factory parameter, + * `maybeBind` returns `undefined` rather than creating an import where the file binds its modules + * with `require`, or where no legal identifier can be derived from the module and none was named. */ export function maybeAddImport( visitor: JavaScriptVisitor, diff --git a/rewrite-javascript/rewrite/src/javascript/scope.ts b/rewrite-javascript/rewrite/src/javascript/scope.ts index 781b55739f..30ff5de711 100644 --- a/rewrite-javascript/rewrite/src/javascript/scope.ts +++ b/rewrite-javascript/rewrite/src/javascript/scope.ts @@ -45,7 +45,12 @@ export function scopeOf(cursor: Cursor): Scope { * shadow it at one of them. */ export function namesDeclaredIn(cu: JS.CompilationUnit): ReadonlySet { - const cached = declared.get(cu); + return namesDeclaredWithin(cu.statements, cu); +} + +/** As {@link namesDeclaredIn}, over one subtree, for a binding shared across only that much of a file. */ +export function namesDeclaredWithin(node: unknown, cacheKey: object = node as object): ReadonlySet { + const cached = declared.get(cacheKey); if (cached) { return cached; } @@ -63,9 +68,9 @@ export function namesDeclaredIn(cu: JS.CompilationUnit): ReadonlySet { } return false; }; - walk(cu.statements, collect); + walk(node, collect); - declared.set(cu, names); + declared.set(cacheKey, names); return names; } @@ -222,7 +227,7 @@ const blockScoped = new Set(['let', 'const', 'using']); // replaced one is walked afresh: every call site in a function asks what that body hoists, and every // import added to a file asks what that file declares. const hoisted = new WeakMap(); -const declared = new WeakMap>(); +const declared = new WeakMap>(); /** * The names blocks under `scope` hoist out to it. A `var` or function declaration reaches the whole diff --git a/rewrite-javascript/rewrite/src/javascript/templating/template.ts b/rewrite-javascript/rewrite/src/javascript/templating/template.ts index 9e2ef27329..f33b2dedb1 100644 --- a/rewrite-javascript/rewrite/src/javascript/templating/template.ts +++ b/rewrite-javascript/rewrite/src/javascript/templating/template.ts @@ -177,6 +177,7 @@ export class TemplateBuilder { export class Template { private options: TemplateOptions = {}; private _cachedTemplate?: J; + private _contextBindings?: Promise; /** * Creates a new template. @@ -224,6 +225,7 @@ export class Template { this.options = {...this.options, ...options}; // Invalidate cache when configuration changes this._cachedTemplate = undefined; + this._contextBindings = undefined; return this; } @@ -325,6 +327,10 @@ export class Template { /** What this template's context statements bind, which is what it needs bound in the target file. */ private contextBindings(): Promise { + return this._contextBindings ??= this.deriveContextBindings(); + } + + private deriveContextBindings(): Promise { return TemplateEngine.getContextBindings( this.templateParts, this.parameters, this.options.context || this.options.imports || [], @@ -403,8 +409,9 @@ export class Template { for (const binding of await this.contextBindings()) { const bound = options.bindings[binding.name]; if (bound === undefined) { - throw new Error(`Template binds '${binding.name}' in its context but was applied without a local name for it. ` + - `Pass bindings: await template.resolveBindings(visitor) to apply().`); + throw new Error(`Template binds '${binding.module}' in its context, but no local name was ` + + `given for '${binding.name}'. Either pass bindings from resolveBindings(visitor), or — if it ` + + `already did — binding was refused, which an AMD block or a file requiring its modules can do.`); } renames[binding.name] = bound; modules[binding.name] = binding.module!; diff --git a/rewrite-javascript/rewrite/test/javascript/binding.test.ts b/rewrite-javascript/rewrite/test/javascript/binding.test.ts index a8648806a0..9342c499e2 100644 --- a/rewrite-javascript/rewrite/test/javascript/binding.test.ts +++ b/rewrite-javascript/rewrite/test/javascript/binding.test.ts @@ -715,6 +715,61 @@ describe("maybeBind", () => { }); }); + test("a type-only import does not answer a request for a value", async () => { + // The name a type-only import binds erases, so reusing it would emit an unbound reference. + const spec = new RecipeSpec(); + const bound: {name?: string} = {}; + spec.recipe = fromVisitor(rebind("m", bound)); + await spec.rewriteRun(typescript( + `import type X from "m";\ntarget();`, + `import type X from "m";\nimport m from "m";\nm.target();`)); + expect(bound.name).toBe("m"); + }); + + test("a file that exports is a module, whatever else it requires", async () => { + // `export` settles the module system, so a `require` alongside it does not make the file + // CommonJS and does not block creating an import. + const spec = new RecipeSpec(); + const bound: {name?: string} = {}; + spec.recipe = fromVisitor(rebind("m", bound)); + await spec.rewriteRun(typescript( + `export function f() {}\nconst p = require("path");\ntarget();`, + `import m from "m";\n\nexport function f() {}\nconst p = require("path");\nm.target();`)); + expect(bound.name).toBe("m"); + }); + + test("an alias is bound verbatim or not at all", async () => { + // A caller naming an alias may already have emitted code using it, so another name for + // the same module does not answer the request and a deconflicted spelling is refused. + const spec = new RecipeSpec(); + const bound: {name?: string} = {}; + spec.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitMethodInvocation(m: J.MethodInvocation, p: any): Promise { + if (m.name.simpleName !== "target") { + return super.visitMethodInvocation(m, p); + } + bound.name = maybeBind(this, {module: "m", alias: "Pinned"}); + return bound.name === undefined ? m : withReference(m, bound.name); + } + }); + await spec.rewriteRun(typescript( + `import Y from "m";\ntarget();`, + `import Y from "m";\nimport Pinned from "m";\nPinned.target();`)); + expect(bound.name).toBe("Pinned"); + }); + + test("a factory parameter clears every name the factory declares, not only those in scope", async () => { + // The parameter is visible across the whole body, and the queue hands its name to later + // requests at cursors inside it, so a name declared in a nested scope still shadows it. + const spec = new RecipeSpec(); + const bound: {name?: string} = {}; + spec.recipe = fromVisitor(rebind("a/Theming", bound)); + await spec.rewriteRun(javascript( + `sap.ui.define([], function () { target(); function f() { var Theming = 1; return Theming; } });`, + `sap.ui.define(["a/Theming"], function (Theming_1) { Theming_1.target(); function f() { var Theming = 1; return Theming; } });`)); + expect(bound.name).toBe("Theming_1"); + }); + describe("maybeRebind", () => { test("an ESM member move keeps the local name", async () => { const spec = new RecipeSpec(); @@ -981,6 +1036,47 @@ describe("maybeUnbind on an ESM file", () => { await removeWith(v => maybeRemoveImport(v, "sap/m/Button")); await removeWith(v => maybeUnbind(v, {module: "sap/m/Button"})); }); + + test("a block with no dependency array keeps its loader parameters", async () => { + // `define(factory)` takes `require`, `exports` and `module` from the loader by position, + // so dropping one there shifts what the rest receive. + const spec = new RecipeSpec(); + spec.recipe = fromVisitor(renameThenSweep({exports: "elsewhere"})); + await spec.rewriteRun(javascript( + `define(function (require, exports, module) { exports.x(); });`, + `define(function (require, exports, module) { elsewhere.x(); });`)); + }); + + test("two calls for one move are one rebind", async () => { + // Each matched reference asks, but the block is rewritten once: a second queued visitor + // would find the dependency already moved and fail the recipe. + const spec = new RecipeSpec(); + spec.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitMethodInvocation(m: J.MethodInvocation, p: any): Promise { + if (m.name.simpleName !== "target") { + return super.visitMethodInvocation(m, p); + } + maybeRebind(this, {from: {module: "a/Old"}, to: {module: "a/New"}}); + return m; + } + }); + await spec.rewriteRun(javascript( + `sap.ui.define(["a/Old"], function (Old) { target(); target(); });`, + `sap.ui.define(["a/New"], function (Old) { target(); target(); });`)); + }); + + test("moving the only named member out leaves no empty braces", async () => { + const spec = new RecipeSpec(); + spec.recipe = fromVisitor(new class extends JavaScriptVisitor { + override async visitJsCompilationUnit(cu: JS.CompilationUnit, p: any): Promise { + maybeRebind(this, {from: {module: "m", member: "a"}, to: {module: "m2", member: "a"}}); + return super.visitJsCompilationUnit(cu, p); + } + }); + await spec.rewriteRun(typescript( + `import D, {a} from "m";\na();`, + `import D from "m";\nimport {a} from "m2";\na();`)); + }); }); /** Renames a call's `select` identifier per `renames`, then sweeps whatever the rename orphaned. */