@openrewrite/rewrite 8.91.4 → 8.91.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/javascript/add-import.js +4 -6
  2. package/dist/javascript/add-import.js.map +1 -1
  3. package/dist/javascript/index.d.ts +1 -1
  4. package/dist/javascript/index.d.ts.map +1 -1
  5. package/dist/javascript/index.js +2 -1
  6. package/dist/javascript/index.js.map +1 -1
  7. package/dist/javascript/remove-import.d.ts +0 -18
  8. package/dist/javascript/remove-import.d.ts.map +1 -1
  9. package/dist/javascript/remove-import.js +50 -438
  10. package/dist/javascript/remove-import.js.map +1 -1
  11. package/dist/javascript/scope.d.ts +6 -0
  12. package/dist/javascript/scope.d.ts.map +1 -1
  13. package/dist/javascript/scope.js +87 -7
  14. package/dist/javascript/scope.js.map +1 -1
  15. package/dist/rewrite-javascript-version.txt +1 -1
  16. package/dist/rpc/index.d.ts +1 -1
  17. package/dist/rpc/index.d.ts.map +1 -1
  18. package/dist/rpc/index.js +2 -1
  19. package/dist/rpc/index.js.map +1 -1
  20. package/dist/rpc/java-recipe.d.ts +32 -22
  21. package/dist/rpc/java-recipe.d.ts.map +1 -1
  22. package/dist/rpc/java-recipe.js +91 -30
  23. package/dist/rpc/java-recipe.js.map +1 -1
  24. package/dist/rpc/request/prepare-recipe.d.ts +10 -14
  25. package/dist/rpc/request/prepare-recipe.d.ts.map +1 -1
  26. package/dist/rpc/request/prepare-recipe.js +53 -88
  27. package/dist/rpc/request/prepare-recipe.js.map +1 -1
  28. package/dist/rpc/rewrite-rpc.d.ts +5 -12
  29. package/dist/rpc/rewrite-rpc.d.ts.map +1 -1
  30. package/dist/rpc/rewrite-rpc.js +5 -12
  31. package/dist/rpc/rewrite-rpc.js.map +1 -1
  32. package/package.json +1 -1
  33. package/src/javascript/add-import.ts +4 -6
  34. package/src/javascript/index.ts +1 -1
  35. package/src/javascript/remove-import.ts +50 -424
  36. package/src/javascript/scope.ts +103 -7
  37. package/src/rpc/index.ts +1 -1
  38. package/src/rpc/java-recipe.ts +107 -33
  39. package/src/rpc/request/prepare-recipe.ts +55 -90
  40. package/src/rpc/rewrite-rpc.ts +5 -12
@@ -152,6 +152,69 @@ export function namesDeclaredWithin(node: unknown, cacheKey: object = node as ob
152
152
  return names;
153
153
  }
154
154
 
155
+ /**
156
+ * The names `node` reads and nothing within it binds, so each one reaches a binding further out.
157
+ * A name its parent introduces rather than reads is not one, nor is a name an inner scope rebinds:
158
+ * that reference reads the rebinding.
159
+ */
160
+ export function namesReferencedWithin(node: unknown, cacheKey: object = node as object): ReadonlySet<string> {
161
+ if (cacheKey === null || typeof cacheKey !== 'object') {
162
+ return noNames;
163
+ }
164
+ const cached = referenced.get(cacheKey);
165
+ if (cached) {
166
+ return cached;
167
+ }
168
+ const names = new Set<string>();
169
+ collectReferences(node, undefined, undefined, names);
170
+ referenced.set(cacheKey, names);
171
+ return names;
172
+ }
173
+
174
+ /** The scopes enclosing a node, innermost first, each paired with what {@link frameBindings} reads. */
175
+ interface Frames {
176
+ node: unknown;
177
+ parent: unknown;
178
+ outer?: Frames;
179
+ }
180
+
181
+ /** Whether a scope between the name and where the walk began binds it. */
182
+ function shadowed(scopes: Frames | undefined, name: string): boolean {
183
+ for (let scope = scopes; scope; scope = scope.outer) {
184
+ if (frameBindings(scope.node, scope.parent).has(name)) {
185
+ return true;
186
+ }
187
+ }
188
+ return false;
189
+ }
190
+
191
+ function collectReferences(node: unknown, parent: unknown, scopes: Frames | undefined, names: Set<string>): void {
192
+ if (Array.isArray(node)) {
193
+ node.forEach(child => collectReferences(child, parent, scopes, names));
194
+ return;
195
+ }
196
+ const kind = (node as { kind?: string } | undefined)?.kind;
197
+ if (kind === J.Kind.RightPadded || kind === J.Kind.LeftPadded) {
198
+ // A naming position reads against the tree parent, so padding forwards the one it was handed.
199
+ collectReferences((node as J.RightPadded<any>).element, parent, scopes, names);
200
+ return;
201
+ }
202
+ if (kind === J.Kind.Container) {
203
+ collectReferences((node as J.Container<any>).elements, parent, scopes, names);
204
+ return;
205
+ }
206
+ if (!isTree(node)) {
207
+ return;
208
+ }
209
+ const name = kind === J.Kind.Identifier ? (node as J.Identifier).simpleName : undefined;
210
+ if (name && reads(node as J.Identifier, parent) && !shadowed(scopes, name)) {
211
+ names.add(name);
212
+ }
213
+ const within = scopeKinds.has(kind!) ? {node, parent, outer: scopes} : scopes;
214
+ Object.entries(node as object).forEach(([key, value]) =>
215
+ key !== 'markers' && collectReferences(value, node, within, names));
216
+ }
217
+
155
218
  /**
156
219
  * Every name a binding pattern introduces. `member` is the property a name takes its value from,
157
220
  * which only a name an object pattern binds directly has: anything deeper reads a property of a
@@ -213,8 +276,12 @@ function frameBindings(node: any, parent: any): ReadonlySet<string> {
213
276
  // A function expression is the only function whose own name its body reaches: a declaration's
214
277
  // name belongs to the enclosing block, a method's to an instance.
215
278
  if (node?.kind === J.Kind.MethodDeclaration && parent?.kind === JS.Kind.StatementExpression) {
216
- const self = bindingNames((node as J.MethodDeclaration).name).map(bound => bound.name);
217
- return new Set([...self, ...ownBindings(node)]);
279
+ let names = selfNamed.get(node);
280
+ if (!names) {
281
+ const self = bindingNames((node as J.MethodDeclaration).name).map(bound => bound.name);
282
+ selfNamed.set(node, names = new Set([...self, ...ownBindings(node)]));
283
+ }
284
+ return names;
218
285
  }
219
286
  return ownBindings(node);
220
287
  }
@@ -369,7 +436,9 @@ const blockScoped = new Set(['let', 'const', 'using']);
369
436
  const hoisted = new WeakMap<object, string[]>();
370
437
  const declared = new WeakMap<object, ReadonlySet<string>>();
371
438
  const used = new WeakMap<object, ReadonlySet<string>>();
439
+ const referenced = new WeakMap<object, ReadonlySet<string>>();
372
440
  const frames = new WeakMap<object, ReadonlySet<string>>();
441
+ const selfNamed = new WeakMap<object, ReadonlySet<string>>();
373
442
 
374
443
  /**
375
444
  * The names blocks under `scope` hoist out to it. A `var` or function declaration reaches the whole
@@ -496,19 +565,46 @@ export function isValueReference(cursor: Cursor, identifier: J.Identifier): bool
496
565
  while (c && isPadding(c.value)) {
497
566
  c = c.parent;
498
567
  }
499
- const parent = c?.value as
500
- { kind?: string; name?: unknown; key?: unknown; label?: unknown; select?: unknown } | undefined;
568
+ return references(identifier, c?.value);
569
+ }
570
+
571
+ /** The position half of {@link isValueReference}, against a parent already found. */
572
+ function references(identifier: J.Identifier, parent: unknown): boolean {
573
+ const owner = parent as {
574
+ kind?: string; name?: unknown; key?: unknown; label?: unknown; select?: unknown;
575
+ propertyName?: unknown;
576
+ } | undefined;
501
577
 
502
578
  // A call names a member of whatever it selects from. With nothing selected there is no member,
503
579
  // and its `name` is a reference to the function being called.
504
- if (parent?.kind === J.Kind.MethodInvocation && !parent.select) {
580
+ if (owner?.kind === J.Kind.MethodInvocation && !owner.select) {
505
581
  return true;
506
582
  }
507
583
 
584
+ // A binding element names the property it destructures and binds under `name`, so both slots
585
+ // name rather than reference.
586
+ if (owner?.kind === JS.Kind.BindingElement && holds(owner.propertyName, identifier)) {
587
+ return false;
588
+ }
589
+
508
590
  // A JSX attribute keeps its prop name on `key`; the three label-bearing nodes keep theirs on
509
591
  // `label`. Every other kind that names rather than references keeps it on `name`.
510
- const named = parent?.kind === JS.Kind.JsxAttribute ? parent.key : (parent?.name ?? parent?.label);
511
- return named !== identifier && (named as { element?: unknown } | undefined)?.element !== identifier;
592
+ return !holds(owner?.kind === JS.Kind.JsxAttribute ? owner.key : (owner?.name ?? owner?.label), identifier);
593
+ }
594
+
595
+ /** Whether a node slot, padded or not, is the identifier itself. */
596
+ function holds(slot: unknown, identifier: J.Identifier): boolean {
597
+ return slot === identifier || (slot as { element?: unknown } | undefined)?.element === identifier;
598
+ }
599
+
600
+ /**
601
+ * As {@link references}, for a collector rather than a renamer: a shorthand property's name slot is
602
+ * also the value it reads, which a rename has to expand rather than follow.
603
+ */
604
+ function reads(identifier: J.Identifier, parent: unknown): boolean {
605
+ const owner = parent as { kind?: string; initializer?: unknown } | undefined;
606
+ return (owner?.kind === JS.Kind.PropertyAssignment && owner.initializer === undefined) ||
607
+ references(identifier, parent);
512
608
  }
513
609
 
514
610
  function isPadding(value: unknown): boolean {
package/src/rpc/index.ts CHANGED
@@ -33,7 +33,7 @@ export * from "./queue";
33
33
  export * from "../reference";
34
34
  export {RewriteRpc} from "./rewrite-rpc";
35
35
  export {RpcRecipe, RpcVisitor} from "./recipe";
36
- export {prepareJavaRecipe} from "./java-recipe";
36
+ export {DelegatingRecipe, prepareJavaRecipe} from "./java-recipe";
37
37
  export {registerVisitor} from "./request/visitor-registry";
38
38
 
39
39
  RpcCodecs.registerCodec(TreeKind.Checksum, {
@@ -1,5 +1,5 @@
1
1
  /*
2
- * Copyright 2025 the original author or authors.
2
+ * Copyright 2026 the original author or authors.
3
3
  * <p>
4
4
  * Licensed under the Moderne Source Available License (the "License");
5
5
  * you may not use this file except in compliance with the License.
@@ -15,23 +15,110 @@
15
15
  */
16
16
  import {RewriteRpc} from "./rewrite-rpc";
17
17
  import type {RpcRecipe} from "./recipe";
18
+ import {Recipe, RecipeDescriptor, ScanningRecipe} from "../recipe";
19
+ import {TreeVisitor} from "../visitor";
20
+ import {ExecutionContext} from "../execution";
21
+ import {SourceFile} from "../tree";
18
22
 
19
23
  /**
20
- * Prepare a Java recipe via the active {@link RewriteRpc} connection.
21
- *
22
- * Routes a `PrepareRecipe` request to whichever side hosts the Java
23
- * implementation:
24
- * - in production, the Java host that spawned the TS server (`dist/rpc/server.js`);
25
- * - in tests, the JVM spawned via `JavaRpcTestServer` (see
26
- * `@openrewrite/rewrite/test/java-rpc`).
27
- *
28
- * The returned {@link RpcRecipe} extends `Recipe`, so it can be used directly
29
- * as a recipe — e.g. assigned to `RecipeSpec.recipe` — or composed inside a
30
- * larger TS recipe (as the editor of a `check(usesType(...), ...)`
31
- * precondition, or as a step in `recipeList()`).
32
- *
33
- * Top-level convenience functions for specific Java recipes (e.g.
34
- * `addDependency`, `changeType`) sit naturally on top of this primitive:
24
+ * A stand-in for a Java recipe, composable like any other recipe (a `recipeList()` entry, a
25
+ * `check(...)` gate, a `RecipeSpec.recipe`). The Java host resolves it by name: from `delegatesTo`
26
+ * when it prepares the enclosing recipe, or by class name when it gates a visitor, like a `RecipeRef`.
27
+ * Only when this process runs it itself (a test, a JS-hosted run) is it prepared over the active
28
+ * {@link RewriteRpc} connection, on first use.
29
+ */
30
+ export class DelegatingRecipe extends ScanningRecipe<number> {
31
+ readonly name: string;
32
+ readonly displayName: string;
33
+ readonly description: string;
34
+ private delegate?: {rpc: RewriteRpc, recipe: Promise<RpcRecipe>};
35
+
36
+ constructor(readonly javaRecipeName: string,
37
+ readonly delegatesToOptions: Record<string, any>) {
38
+ super();
39
+ this.name = javaRecipeName;
40
+ this.displayName = javaRecipeName;
41
+ this.description = `Delegates to the Java recipe \`${javaRecipeName}\`.`;
42
+ }
43
+
44
+ // Local, with an empty recipe list: the host fills the subtree in, and recipeList() prepares the delegate.
45
+ async descriptor(): Promise<RecipeDescriptor> {
46
+ return {
47
+ name: this.name,
48
+ displayName: this.displayName,
49
+ instanceName: this.instanceName(),
50
+ description: this.description,
51
+ tags: this.tags,
52
+ estimatedEffortPerOccurrence: this.estimatedEffortPerOccurrence,
53
+ options: Object.entries(this.delegatesToOptions).map(([name, value]) => ({
54
+ name,
55
+ value,
56
+ displayName: name,
57
+ description: "",
58
+ required: false
59
+ })),
60
+ preconditions: [],
61
+ recipeList: [],
62
+ dataTables: this.dataTables,
63
+ maintainers: [],
64
+ contributors: [],
65
+ examples: []
66
+ };
67
+ }
68
+
69
+ async recipeList(): Promise<Recipe[]> {
70
+ return (await this.prepared()).recipeList();
71
+ }
72
+
73
+ initialValue(_ctx: ExecutionContext): number {
74
+ return 0;
75
+ }
76
+
77
+ async editorWithData(acc: number): Promise<TreeVisitor<any, ExecutionContext>> {
78
+ return (await this.prepared()).editorWithData(acc);
79
+ }
80
+
81
+ async scanner(acc: number): Promise<TreeVisitor<any, ExecutionContext>> {
82
+ return (await this.prepared()).scanner(acc);
83
+ }
84
+
85
+ async generate(acc: number, ctx: ExecutionContext): Promise<SourceFile[]> {
86
+ return (await this.prepared()).generate(acc, ctx);
87
+ }
88
+
89
+ async onComplete(ctx: ExecutionContext): Promise<void> {
90
+ if (this.delegate) {
91
+ await (await this.delegate.recipe).onComplete(ctx);
92
+ }
93
+ }
94
+
95
+ private prepared(): Promise<RpcRecipe> {
96
+ const rpc = RewriteRpc.get();
97
+ if (!rpc) {
98
+ throw new Error(
99
+ `Cannot run Java recipe "${this.javaRecipeName}": no active RewriteRpc connection.\n` +
100
+ " • Tests: spawn one via JavaRpcTestServer.start() — see " +
101
+ "@openrewrite/rewrite/test/java-rpc.\n" +
102
+ " • Production: the Java host provides one automatically when it " +
103
+ "loads the TS recipe artifact."
104
+ );
105
+ }
106
+ // Prepared once per connection; a failed prepare is dropped so the next use retries.
107
+ if (!this.delegate || this.delegate.rpc !== rpc) {
108
+ const recipe = rpc.prepareRecipe(this.javaRecipeName, this.delegatesToOptions);
109
+ this.delegate = {rpc, recipe};
110
+ recipe.catch(() => {
111
+ if (this.delegate?.recipe === recipe) {
112
+ this.delegate = undefined;
113
+ }
114
+ });
115
+ }
116
+ return this.delegate.recipe;
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Reference a Java recipe by name from a TS recipe:
35
122
  *
36
123
  * ```ts
37
124
  * export function addDependency(options: AddDependencyOptions): Promise<Recipe> {
@@ -39,26 +126,13 @@ import type {RpcRecipe} from "./recipe";
39
126
  * }
40
127
  * ```
41
128
  *
42
- * There is no in-process fallback by design: a Java recipe's editor is the
43
- * single source of truth — we don't reimplement editing recipes in TS. For
44
- * preconditions that need a graceful no-RPC fallback, use the {@link RecipeRef}
129
+ * There is no in-process fallback by design: a Java recipe's editor is the single source of
130
+ * truth. For preconditions that need a graceful no-RPC fallback, use the {@link RecipeRef}
45
131
  * pattern via `usesType` / `usesMethod`, which carries a `localVisitor`.
46
- *
47
- * @throws Error when no active {@link RewriteRpc} connection is registered.
48
132
  */
49
133
  export async function prepareJavaRecipe(
50
134
  id: string,
51
135
  options?: Record<string, any>,
52
- ): Promise<RpcRecipe> {
53
- const rpc = RewriteRpc.get();
54
- if (!rpc) {
55
- throw new Error(
56
- `Cannot prepare Java recipe "${id}": no active RewriteRpc connection.\n` +
57
- " • Tests: spawn one via JavaRpcTestServer.start() — see " +
58
- "@openrewrite/rewrite/test/java-rpc.\n" +
59
- " • Production: the Java host provides one automatically when it " +
60
- "loads the TS recipe artifact."
61
- );
62
- }
63
- return rpc.prepareRecipe(id, options);
136
+ ): Promise<DelegatingRecipe> {
137
+ return new DelegatingRecipe(id, options ?? {});
64
138
  }
@@ -19,6 +19,7 @@ import {Recipe, RecipeDescriptor, ScanningRecipe} from "../../recipe";
19
19
  import {SnowflakeId} from "@akashrajpurohit/snowflake-id";
20
20
  import {Check, CheckArg, CompositePrecondition, RecipeRef} from "../../preconditions";
21
21
  import {RpcRecipe} from "../recipe";
22
+ import {DelegatingRecipe} from "../java-recipe";
22
23
  import {TreeVisitor} from "../../visitor";
23
24
  import {ExecutionContext} from "../../execution";
24
25
  import {withMetrics} from "./metrics";
@@ -42,22 +43,10 @@ export class PrepareRecipe {
42
43
  context.target = request.id;
43
44
  const recipeCtor = marketplace.findRecipe(request.id);
44
45
  if (!recipeCtor) {
45
- // A miss means the host owns this recipe (e.g. a sub-recipe that delegates to
46
- // a Java recipe, produced by prepareJavaRecipe — an RpcRecipe hosted on the peer
47
- // with no no-arg constructor to register here). Tell the host to resolve the id
48
- // locally via delegatesTo rather than failing with "Could not find recipe ...".
49
- const id = snowflake.generate();
50
- return {
51
- id: id,
52
- descriptor: PrepareRecipe.delegateDescriptor(request.id),
53
- editVisitor: `edit:${id}`,
54
- editPreconditions: [],
55
- scanPreconditions: [],
56
- delegatesTo: {
57
- recipeName: request.id,
58
- options: request.options ?? {}
59
- }
60
- };
46
+ // A miss means the host owns this recipe (e.g. a Java-delegate child a host
47
+ // re-prepares by name), so answer with a delegatesTo stand-in for it to resolve.
48
+ return await PrepareRecipe.prepareInstance(new DelegatingRecipe(request.id, request.options ?? {}),
49
+ snowflake, preparedRecipes, marketplace);
61
50
  }
62
51
  if (!recipeCtor[1]) {
63
52
  throw new Error(`Recipe ${request.id} was installed without a constructor`);
@@ -70,44 +59,30 @@ export class PrepareRecipe {
70
59
  }
71
60
 
72
61
  /**
73
- * Minimal stand-in descriptor for a recipe the host will resolve locally via
74
- * {@link PrepareRecipeResponse.delegatesTo}. The host reads only `delegatesTo` for
75
- * delegated recipes and ignores this descriptor, but the response type requires one.
76
- */
77
- private static delegateDescriptor(name: string): RecipeDescriptor {
78
- return {
79
- name: name,
80
- displayName: name,
81
- instanceName: name,
82
- description: "",
83
- tags: [],
84
- estimatedEffortPerOccurrence: 5,
85
- options: [],
86
- preconditions: [],
87
- recipeList: [],
88
- dataTables: [],
89
- maintainers: [],
90
- contributors: [],
91
- examples: []
92
- };
93
- }
94
-
95
- /**
96
- * Prepares a recipe instance and recursively its whole child tree, registering every node in
97
- * {@code preparedRecipes} and returning the prepared response with {@code recipeList} populated,
98
- * so the host builds the tree locally instead of a PrepareRecipe round-trip per child.
99
- *
100
- * Mirrors the C# server's PrepareInstance. Required options are validated as each node is
101
- * prepared (covering the whole tree). A child hosted on the peer (an {@link RpcRecipe}, e.g. from
102
- * prepareJavaRecipe) carries only {@code delegatesTo} for the host to resolve locally; every other
103
- * child carries its own {@code recipeList}. Delegating recipes forward validation to the recipe
104
- * they delegate to, so they are not validated here.
62
+ * Prepares a recipe and, recursively, its whole child tree, registering every node in
63
+ * {@code preparedRecipes}; the response carries {@code recipeList} so the host builds the tree
64
+ * without a PrepareRecipe round-trip per child (mirrors the C# server's PrepareInstance).
65
+ * Required options are validated per node. A stand-in for a host recipe (see {@link #delegatesTo})
66
+ * carries only {@code delegatesTo} and leaves validation to the host.
105
67
  */
106
68
  private static async prepareInstance(recipe: Recipe,
107
69
  snowflake: ReturnType<typeof SnowflakeId>,
108
70
  preparedRecipes: Map<String, Recipe>,
109
71
  marketplace: RecipeMarketplace): Promise<PrepareRecipeResponse> {
110
72
  const id = snowflake.generate();
73
+ const delegatesTo = PrepareRecipe.delegatesTo(recipe);
74
+ if (delegatesTo) {
75
+ // Registered so a peer that visits `edit:<id>` instead of honouring delegatesTo reaches it.
76
+ preparedRecipes.set(id, recipe);
77
+ return {
78
+ id: id,
79
+ descriptor: await recipe.descriptor(),
80
+ editVisitor: `edit:${id}`,
81
+ editPreconditions: [],
82
+ scanPreconditions: [],
83
+ delegatesTo
84
+ };
85
+ }
111
86
 
112
87
  const editPreconditions: Precondition[] = [];
113
88
  recipe = await PrepareRecipe.optimizePreconditions(recipe, "edit", editPreconditions);
@@ -117,13 +92,9 @@ export class PrepareRecipe {
117
92
  preparedRecipes.set(id, recipe);
118
93
 
119
94
  const descriptor = await recipe.descriptor();
120
- const isDelegating = 'javaRecipeName' in recipe;
121
-
122
- if (!isDelegating) {
123
- for (const option of descriptor.options) {
124
- if ((option.required ?? true) && option.value == null) {
125
- throw new Error(`Missing required option \`${option.name}\` for recipe \`${descriptor.name}\`.`);
126
- }
95
+ for (const option of descriptor.options) {
96
+ if ((option.required ?? true) && option.value == null) {
97
+ throw new Error(`Missing required option \`${option.name}\` for recipe \`${descriptor.name}\`.`);
127
98
  }
128
99
  }
129
100
 
@@ -136,49 +107,43 @@ export class PrepareRecipe {
136
107
  scanPreconditions: scanPreconditions
137
108
  };
138
109
 
139
- if (isDelegating) {
140
- response.delegatesTo = {
141
- recipeName: (recipe as any).javaRecipeName,
142
- options: (recipe as any).delegatesToOptions ?? {}
143
- };
144
- return response;
145
- }
146
-
147
110
  const childResponses: PrepareRecipeResponse[] = [];
148
111
  for (const child of await recipe.recipeList()) {
149
112
  if (child instanceof RpcRecipe) {
150
- // Hosted on the peer: emit delegatesTo (its name + the options its parent set) so the
151
- // host resolves it locally, matching how the by-name fallback used to resolve it.
152
- const childId = snowflake.generate();
153
- const childDescriptor = await child.descriptor();
113
+ // Already prepared on the host: hand it back by name with the options its parent set.
154
114
  const options: Record<string, any> = {};
155
- for (const option of childDescriptor.options) {
115
+ for (const option of (await child.descriptor()).options) {
156
116
  if (option.value != null) {
157
117
  options[option.name] = option.value;
158
118
  }
159
119
  }
160
- childResponses.push({
161
- id: childId,
162
- descriptor: PrepareRecipe.delegateDescriptor(child.name),
163
- editVisitor: `edit:${childId}`,
164
- editPreconditions: [],
165
- scanPreconditions: [],
166
- delegatesTo: {recipeName: child.name, options}
167
- });
168
- } else {
169
- // Register a child that was instantiated in recipeList() but never installed, so a peer
170
- // that re-prepares children by name (rather than consuming recipeList) can still find it.
171
- if (!marketplace.findRecipe(child.name)) {
172
- await marketplace.install(child.constructor as any, []);
173
- }
174
- childResponses.push(await PrepareRecipe.prepareInstance(child, snowflake, preparedRecipes, marketplace));
120
+ childResponses.push(await PrepareRecipe.prepareInstance(new DelegatingRecipe(child.name, options),
121
+ snowflake, preparedRecipes, marketplace));
122
+ continue;
175
123
  }
124
+ // Register a child that was instantiated in recipeList() but never installed, so a peer
125
+ // that re-prepares children by name (rather than consuming recipeList) can still find it.
126
+ if (!PrepareRecipe.delegatesTo(child) && !marketplace.findRecipe(child.name)) {
127
+ await marketplace.install(child.constructor as any, []);
128
+ }
129
+ childResponses.push(await PrepareRecipe.prepareInstance(child, snowflake, preparedRecipes, marketplace));
176
130
  }
177
131
  response.recipeList = childResponses;
178
132
 
179
133
  return response;
180
134
  }
181
135
 
136
+ /**
137
+ * The Java recipe {@code recipe} stands in for, or {@code undefined} for a recipe this process
138
+ * runs itself. Duck-typed on the fields {@link DelegatingRecipe} declares, so a recipe package
139
+ * that loaded its own copy of this module is recognised too.
140
+ */
141
+ private static delegatesTo(recipe: any): {recipeName: string, options: Record<string, any>} | undefined {
142
+ return recipe != null && typeof recipe.javaRecipeName === "string" ?
143
+ {recipeName: recipe.javaRecipeName, options: {...(recipe.delegatesToOptions ?? {})}} :
144
+ undefined;
145
+ }
146
+
182
147
  /**
183
148
  * For preconditions that can be evaluated on the remote peer, let the remote peer
184
149
  * evaluate them and know that we will only have to do the visit work if the
@@ -246,13 +211,13 @@ export class PrepareRecipe {
246
211
  }
247
212
  return {op: condition.op, operands};
248
213
  }
249
- // Common case: helpers like usesMethod / usesType return a lightweight
250
- // RecipeRef so the recipe author can declare a precondition without
251
- // firing an RPC at editor() time. The Java host's
252
- // PreparedRecipeCache.instantiateVisitor constructs the named recipe
253
- // via Jackson and uses its visitor.
254
- if (condition instanceof RecipeRef) {
255
- return {visitorName: condition.recipeName, visitorOptions: {...condition.options}};
214
+ // Common case: usesMethod / usesType return a lightweight RecipeRef, and
215
+ // prepareJavaRecipe a DelegatingRecipe, so a precondition is declared without
216
+ // firing an RPC at editor() time. The Java host's PreparedRecipeCache
217
+ // .instantiateVisitor constructs the named recipe via Jackson and uses its visitor.
218
+ const ref = condition instanceof RecipeRef ? condition : PrepareRecipe.delegatesTo(condition);
219
+ if (ref) {
220
+ return {visitorName: ref.recipeName, visitorOptions: {...ref.options}};
256
221
  }
257
222
  if (condition instanceof RpcRecipe) {
258
223
  return {visitorName: phase === "edit" ? condition.editVisitor : condition.scanVisitor!};
@@ -50,18 +50,11 @@ import {GetLanguages} from "./request/get-languages";
50
50
 
51
51
  export class RewriteRpc {
52
52
  /**
53
- * Key for the active {@link RewriteRpc} connection on {@link globalThis}.
54
- *
55
- * It deliberately lives on `globalThis` rather than as a `static` field:
56
- * a recipe package that bundles `@openrewrite/rewrite` (or resolves it from
57
- * its own `node_modules`) loads a *separate copy* of this module, with its
58
- * own class object and therefore its own statics. A `static` field set by
59
- * the host would be invisible to such a copy, so `RewriteRpc.get()` (e.g.
60
- * via `prepareJavaRecipe`) would return `undefined` and throw "no active
61
- * RewriteRpc connection" — surfacing during `InstallRecipes` as the
62
- * misleading "Ensure the constructor can be called without any arguments".
63
- * {@link Symbol.for} resolves to the same symbol across every module copy,
64
- * so all copies share the one active connection. See gh-7968.
53
+ * Key for the active {@link RewriteRpc} connection on {@link globalThis}. A recipe package that
54
+ * bundles `@openrewrite/rewrite` or resolves it from its own `node_modules` loads a separate copy
55
+ * of this module with its own class object and statics, so a `static` field set by the host would
56
+ * be invisible to it; {@link Symbol.for} resolves to the same symbol across every module copy, so
57
+ * all copies share the one active connection. See gh-7968.
65
58
  */
66
59
  private static readonly GLOBAL_KEY: symbol = Symbol.for("org.openrewrite.rpc.RewriteRpc.global");
67
60