@openrewrite/recipes-nodejs 0.52.0-20261009-102010 → 0.52.1

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 (60) hide show
  1. package/dist/index.d.ts.map +1 -1
  2. package/dist/index.js +2 -0
  3. package/dist/index.js.map +1 -1
  4. package/dist/migrate/code-style.d.ts +14 -0
  5. package/dist/migrate/code-style.d.ts.map +1 -0
  6. package/dist/migrate/code-style.js +70 -0
  7. package/dist/migrate/code-style.js.map +1 -0
  8. package/dist/migrate/hpagent/find-hpagent-manual-migrations.d.ts.map +1 -1
  9. package/dist/migrate/hpagent/find-hpagent-manual-migrations.js +36 -133
  10. package/dist/migrate/hpagent/find-hpagent-manual-migrations.js.map +1 -1
  11. package/dist/migrate/hpagent/hpagent-support.d.ts +36 -15
  12. package/dist/migrate/hpagent/hpagent-support.d.ts.map +1 -1
  13. package/dist/migrate/hpagent/hpagent-support.js +294 -55
  14. package/dist/migrate/hpagent/hpagent-support.js.map +1 -1
  15. package/dist/migrate/hpagent/replace-hpagent.d.ts.map +1 -1
  16. package/dist/migrate/hpagent/replace-hpagent.js +510 -153
  17. package/dist/migrate/hpagent/replace-hpagent.js.map +1 -1
  18. package/dist/migrate/source-map-support/enable-source-maps-at-launch.d.ts.map +1 -1
  19. package/dist/migrate/source-map-support/enable-source-maps-at-launch.js +8 -16
  20. package/dist/migrate/source-map-support/enable-source-maps-at-launch.js.map +1 -1
  21. package/dist/migrate/source-map-support/find-source-map-support-manual-migrations.d.ts.map +1 -1
  22. package/dist/migrate/source-map-support/find-source-map-support-manual-migrations.js +26 -9
  23. package/dist/migrate/source-map-support/find-source-map-support-manual-migrations.js.map +1 -1
  24. package/dist/migrate/source-map-support/migrate-source-map-support-to-native.d.ts.map +1 -1
  25. package/dist/migrate/source-map-support/migrate-source-map-support-to-native.js +7 -4
  26. package/dist/migrate/source-map-support/migrate-source-map-support-to-native.js.map +1 -1
  27. package/dist/migrate/source-map-support/replace-source-map-support-api.d.ts +9 -0
  28. package/dist/migrate/source-map-support/replace-source-map-support-api.d.ts.map +1 -0
  29. package/dist/migrate/source-map-support/replace-source-map-support-api.js +275 -0
  30. package/dist/migrate/source-map-support/replace-source-map-support-api.js.map +1 -0
  31. package/dist/migrate/source-map-support/replace-source-map-support-install.d.ts.map +1 -1
  32. package/dist/migrate/source-map-support/replace-source-map-support-install.js +2 -1
  33. package/dist/migrate/source-map-support/replace-source-map-support-install.js.map +1 -1
  34. package/dist/migrate/source-map-support/source-map-support-api-source.d.ts +14 -0
  35. package/dist/migrate/source-map-support/source-map-support-api-source.d.ts.map +1 -0
  36. package/dist/migrate/source-map-support/source-map-support-api-source.js +426 -0
  37. package/dist/migrate/source-map-support/source-map-support-api-source.js.map +1 -0
  38. package/dist/migrate/source-map-support/source-map-support-rewrites.d.ts.map +1 -1
  39. package/dist/migrate/source-map-support/source-map-support-rewrites.js +2 -0
  40. package/dist/migrate/source-map-support/source-map-support-rewrites.js.map +1 -1
  41. package/dist/migrate/source-map-support/source-map-support-support.d.ts +11 -3
  42. package/dist/migrate/source-map-support/source-map-support-support.d.ts.map +1 -1
  43. package/dist/migrate/source-map-support/source-map-support-support.js +69 -10
  44. package/dist/migrate/source-map-support/source-map-support-support.js.map +1 -1
  45. package/dist/resources/advisories-npm.csv +342 -6
  46. package/package.json +2 -2
  47. package/src/index.ts +2 -0
  48. package/src/migrate/code-style.ts +92 -0
  49. package/src/migrate/fs-extra/replace-fs-extra-native-methods.ts +2 -2
  50. package/src/migrate/hpagent/find-hpagent-manual-migrations.ts +45 -142
  51. package/src/migrate/hpagent/hpagent-support.ts +358 -60
  52. package/src/migrate/hpagent/replace-hpagent.ts +580 -189
  53. package/src/migrate/source-map-support/enable-source-maps-at-launch.ts +17 -26
  54. package/src/migrate/source-map-support/find-source-map-support-manual-migrations.ts +25 -9
  55. package/src/migrate/source-map-support/migrate-source-map-support-to-native.ts +7 -4
  56. package/src/migrate/source-map-support/replace-source-map-support-api.ts +281 -0
  57. package/src/migrate/source-map-support/replace-source-map-support-install.ts +2 -1
  58. package/src/migrate/source-map-support/source-map-support-api-source.ts +470 -0
  59. package/src/migrate/source-map-support/source-map-support-rewrites.ts +3 -0
  60. package/src/migrate/source-map-support/source-map-support-support.ts +79 -10
@@ -4,25 +4,57 @@
4
4
  * Moderne Proprietary. Only for use by Moderne customers under the terms of a commercial contract.
5
5
  */
6
6
 
7
- import {emptyMarkers, ExecutionContext, Option, randomId, Recipe, TreeVisitor} from "@openrewrite/rewrite";
8
- import {isIdentifier, J, rightPadded, space} from "@openrewrite/rewrite/java";
9
- import {compilationUnitOf, declarationsOf, JavaScriptVisitor, JS, maybeRebind, maybeUnbind, namesDeclaredIn} from "@openrewrite/rewrite/javascript";
10
- import {requiredModule} from "../module-bindings";
7
+ import {Cursor, emptyMarkers, ExecutionContext, findMarker, Option, randomId, Recipe, TreePrinters, TreeVisitor} from "@openrewrite/rewrite";
8
+ import {emptySpace, isIdentifier, J, rightPadded, space} from "@openrewrite/rewrite/java";
11
9
  import {
12
- bindingsOf,
10
+ autoFormat,
11
+ bindingNames,
12
+ compilationUnitOf,
13
+ declarationsOf,
14
+ deconflict,
15
+ isReference,
16
+ JavaScriptParser,
17
+ JavaScriptVisitor,
18
+ JS,
19
+ maybeRebind,
20
+ maybeUnbind,
21
+ namesUsedIn,
22
+ StyleKind
23
+ } from "@openrewrite/rewrite/javascript";
24
+ import {detectCodeStyle} from "../code-style";
25
+ import {bindingsOf, importModule, requiredModule} from "../module-bindings";
26
+ import {
27
+ ADAPTED_TYPES,
28
+ ADAPTER_FUNCTION,
29
+ ADAPTER_TYPE,
30
+ adapterSource,
31
+ adapterTypeSource,
32
+ builtinAgentModule,
33
+ builtinAgentType,
13
34
  classifyOption,
35
+ constructionArgument,
36
+ emptyHpagentBindings,
37
+ GeneratedDeclaration,
14
38
  HPAGENT_CLASSES,
15
39
  HPAGENT_MODULES,
40
+ hpagentBindings,
41
+ HpagentBindings,
42
+ inTypePosition,
43
+ isHpagentRequire,
16
44
  isKnownProxyRequestOptions,
45
+ isMockCall,
17
46
  isObjectLiteral,
18
- isModuleScoped,
19
- spreadIsPlainAgentOptions,
47
+ isValueUse,
20
48
  ModuleBindings,
21
49
  objectMembers,
22
50
  propertyName,
51
+ REQUEST_TYPE,
52
+ resolvesToBinding,
53
+ spiedClass,
54
+ spreadIsPlainAgentOptions,
55
+ stringLiteral,
23
56
  TARGET_CLASS,
24
57
  TARGET_MODULE,
25
- targetTypeReference,
26
58
  } from "./hpagent-support";
27
59
 
28
60
  /**
@@ -36,43 +68,25 @@ interface Rewrite {
36
68
  literal: J.NewClass;
37
69
  }
38
70
 
39
- /** The names a file's top-level `require`s of hpagent bind, whichever destructuring shape they use. */
40
- function requireBoundNames(cu: JS.CompilationUnit): Set<string> {
41
- const names = new Set<string>();
42
- for (const padded of cu.statements) {
43
- for (const decl of declarationsOf(padded.element)) {
44
- for (const v of decl.variables) {
45
- const variable = v.element;
46
- if (!HPAGENT_MODULES.has(requiredModule(variable.initializer?.element) ?? '')) continue;
47
- for (const name of boundNames(variable.name as J)) names.add(name);
48
- }
49
- }
50
- }
51
- return names;
52
- }
53
-
54
- /** The local names a `require`'s binding target introduces. */
55
- function boundNames(target: J): string[] {
56
- if (isIdentifier(target)) return [target.simpleName];
57
- if (target.kind !== JS.Kind.ObjectBindingPattern) return [];
58
- return (target as JS.ObjectBindingPattern).bindings.elements.flatMap(padded => {
59
- const binding = padded.element as J;
60
- if (isIdentifier(binding)) return [binding.simpleName];
61
- if (binding.kind !== JS.Kind.BindingElement) return [];
62
- const name = (binding as JS.BindingElement).name as J;
63
- return isIdentifier(name) ? [name.simpleName] : [];
64
- });
71
+ /** The local name a binding pattern element introduces. */
72
+ function boundLocal(binding: J): string | undefined {
73
+ if (isIdentifier(binding)) return binding.simpleName;
74
+ if (binding.kind !== JS.Kind.BindingElement) return undefined;
75
+ const name = (binding as JS.BindingElement).name as J;
76
+ return isIdentifier(name) ? name.simpleName : undefined;
65
77
  }
66
78
 
67
79
  /**
68
80
  * Renames a `require`'s destructured bindings to the names the migration gives them. A shorthand
69
81
  * `{HttpProxyAgent}` becomes `{HttpsProxyAgent}`; one that already names a property — `{X: local}` —
70
- * keeps its local name and takes the new property.
82
+ * keeps its local name and takes the new property. A binding folded into another is dropped.
71
83
  */
72
- function renameBindings(target: J, targetLocals: ReadonlyMap<string, string>): J {
84
+ function renameBindings(target: J, targetLocals: ReadonlyMap<string, string>, merged: ReadonlySet<string>): J {
73
85
  if (target.kind !== JS.Kind.ObjectBindingPattern) return target;
74
86
  const pattern = target as JS.ObjectBindingPattern;
75
- const elements = pattern.bindings.elements.map(padded => {
87
+ const original = pattern.bindings.elements;
88
+ const kept = original.filter(padded => !merged.has(boundLocal(padded.element as J) ?? ''));
89
+ const elements = kept.map(padded => {
76
90
  const binding = padded.element as J;
77
91
  if (isIdentifier(binding)) {
78
92
  const renamed = targetLocals.get(binding.simpleName);
@@ -92,19 +106,76 @@ function renameBindings(target: J, targetLocals: ReadonlyMap<string, string>): J
92
106
  const name = element.name as J;
93
107
  if (!isIdentifier(name)) return padded;
94
108
  const renamed = targetLocals.get(name.simpleName);
95
- return renamed && renamed !== name.simpleName
96
- ? {...padded, element: {...element, name: {...name, simpleName: renamed}} as J}
97
- : padded;
109
+ if (!renamed || renamed === name.simpleName) return padded;
110
+ if (renamed === TARGET_CLASS) return {...padded, element: {...element, name: {...name, simpleName: renamed}} as J};
111
+ // A deconflicted name can't be shorthand, so it reads the class off as `{HttpsProxyAgent: renamed}`.
112
+ return {
113
+ ...padded,
114
+ element: {
115
+ ...element,
116
+ propertyName: rightPadded({...name, id: randomId(), simpleName: TARGET_CLASS} as J.Identifier, emptySpace),
117
+ name: {...name, prefix: space(' '), simpleName: renamed}
118
+ } as J
119
+ };
98
120
  });
121
+ // The padding after the last element holds the space before `}`, so the new last element takes it.
122
+ if (elements.length > 0 && kept[kept.length - 1] !== original[original.length - 1]) {
123
+ elements[elements.length - 1] = {...elements[elements.length - 1], after: original[original.length - 1].after};
124
+ }
99
125
  return {...pattern, bindings: {...pattern.bindings, elements}} as J;
100
126
  }
101
127
 
128
+ function retargeted(literal: J.Literal): J.Literal {
129
+ const quote = (literal.valueSource ?? "'").charAt(0);
130
+ return {...literal, value: TARGET_MODULE, valueSource: `${quote}${TARGET_MODULE}${quote}`};
131
+ }
132
+
133
+ /** `require('hpagent')` naming the new module, together with a `typeof import('hpagent')` it is cast to. */
134
+ function movedRequireCall(e: J): J {
135
+ if (e.kind === JS.Kind.As) {
136
+ const as = e as JS.As;
137
+ return {...as, left: {...as.left, element: movedRequireCall(as.left.element)}, right: movedImportType(as.right)} as J;
138
+ }
139
+ const call = e as J.MethodInvocation;
140
+ const specifier = call.arguments.elements[0];
141
+ return {...call, arguments: {...call.arguments, elements: [{...specifier, element: retargeted(specifier.element as J.Literal)}]}} as J.MethodInvocation;
142
+ }
143
+
144
+ function movedImportType(type: J): J {
145
+ if (type.kind !== JS.Kind.ImportType) return type;
146
+ const importType = type as JS.ImportType;
147
+ const elements = importType.argumentAndAttributes.elements.map(padded => {
148
+ const argument = padded.element;
149
+ if (argument.kind !== JS.Kind.LiteralType) return padded;
150
+ const literal = (argument as JS.LiteralType).literal as J.Literal;
151
+ return HPAGENT_MODULES.has(stringLiteral(literal) ?? '')
152
+ ? {...padded, element: {...argument, literal: retargeted(literal)} as J}
153
+ : padded;
154
+ });
155
+ return {...importType, argumentAndAttributes: {...importType.argumentAndAttributes, elements}} as J;
156
+ }
157
+
102
158
  /** What a type arm denotes, for spotting two arms that say the same thing. */
103
159
  function typeKey(type: J): string | undefined {
104
160
  if (isIdentifier(type)) return type.simpleName;
161
+ if (type.kind === J.Kind.FieldAccess) {
162
+ const access = type as J.FieldAccess;
163
+ return isIdentifier(access.target) ? `${access.target.simpleName}.${access.name.element.simpleName}` : undefined;
164
+ }
165
+ if (type.kind === JS.Kind.ImportType) {
166
+ const importType = type as JS.ImportType;
167
+ const module = importType.argumentAndAttributes.elements[0]?.element as JS.LiteralType | undefined;
168
+ const name = importType.qualifier?.element;
169
+ return module?.kind === JS.Kind.LiteralType && name && isIdentifier(name)
170
+ ? `import(${stringLiteral(module.literal)}).${name.simpleName}`
171
+ : undefined;
172
+ }
105
173
  if (type.kind !== J.Kind.ParameterizedType) return undefined;
106
174
  const clazz = (type as J.ParameterizedType).class as J;
107
- return isIdentifier(clazz) ? `${clazz.simpleName}<>` : undefined;
175
+ if (isIdentifier(clazz)) return `${clazz.simpleName}<>`;
176
+ if (clazz.kind !== J.Kind.FieldAccess) return undefined;
177
+ const access = clazz as J.FieldAccess;
178
+ return isIdentifier(access.target) ? `${access.target.simpleName}.${access.name.element.simpleName}<>` : undefined;
108
179
  }
109
180
 
110
181
  /**
@@ -160,7 +231,7 @@ function isJavaScript(sourcePath: string): boolean {
160
231
  }
161
232
 
162
233
  /** `proxy ? construction : undefined`, built directly so the construction keeps its layout. */
163
- function whenDefined(proxy: J, construction: J.NewClass): J.Ternary {
234
+ function whenDefined(proxy: J, construction: J): J.Ternary {
164
235
  const padded = <T extends J>(element: T): J.LeftPadded<T> =>
165
236
  ({kind: J.Kind.LeftPadded, before: space(' '), element, markers: emptyMarkers});
166
237
  const undefinedValue: J.Identifier = {
@@ -172,7 +243,7 @@ function whenDefined(proxy: J, construction: J.NewClass): J.Ternary {
172
243
  prefix: construction.prefix,
173
244
  markers: emptyMarkers,
174
245
  condition: {...proxy, id: randomId(), prefix: space('')} as J,
175
- truePart: padded({...construction, prefix: space(' ')} as J.NewClass),
246
+ truePart: padded({...construction, prefix: space(' ')} as J),
176
247
  falsePart: padded(undefinedValue),
177
248
  } as J.Ternary;
178
249
  }
@@ -182,6 +253,61 @@ function paddedMembers(literal: J.NewClass): J.RightPadded<J>[] {
182
253
  return (literal.body?.statements ?? []).filter(padded => padded.element.kind !== J.Kind.Empty) as J.RightPadded<J>[];
183
254
  }
184
255
 
256
+ /**
257
+ * A builtin's specifier for a type query. The bare name unless the file already writes `node:`, since
258
+ * `@types/node` declared the prefixed names only from 16, after hpagent's Node 14 floor.
259
+ */
260
+ function builtinSpecifier(cu: JS.CompilationUnit, module: string): string {
261
+ const prefixed = cu.statements.some(padded => {
262
+ const statement = padded.element;
263
+ const specifier = statement?.kind === JS.Kind.Import
264
+ ? importModule(statement as JS.Import)
265
+ : declarationsOf(statement).map(d => d.variables.map(v => requiredModule(v.element.initializer?.element)).find(Boolean)).find(Boolean);
266
+ return specifier?.startsWith('node:');
267
+ });
268
+ return prefixed ? `node:${module}` : module;
269
+ }
270
+
271
+ /** One of hpagent's option types, which the adapter's own types stand in for. */
272
+ function isOptionType(member: string): boolean {
273
+ return ADAPTED_TYPES.has(member) || member === 'ProxyAgentRequestOptions';
274
+ }
275
+
276
+ /** `declare module 'hpagent' {...}`, which declared types for the module the migration drops. */
277
+ function isAmbientHpagent(statement: J | undefined): boolean {
278
+ if (statement?.kind !== JS.Kind.NamespaceDeclaration) return false;
279
+ const declaration = statement as JS.NamespaceDeclaration;
280
+ return declaration.keywordType.element === JS.NamespaceDeclaration.KeywordType.Module &&
281
+ HPAGENT_MODULES.has(stringLiteral(declaration.name.element) ?? '');
282
+ }
283
+
284
+ /** `ConstructorParameters<typeof X>[0]`, the options a constructor takes; the `typeof` operand, if it is that. */
285
+ function constructorOptionsOf(type: JS.IndexedAccessType): J | undefined {
286
+ const index = (type.indexType as JS.IndexedAccessType.IndexType).element.element;
287
+ if (index.kind !== JS.Kind.LiteralType || (index as JS.LiteralType).literal.kind !== J.Kind.Literal ||
288
+ ((index as JS.LiteralType).literal as J.Literal).value !== 0) {
289
+ return undefined;
290
+ }
291
+ if (type.objectType.kind !== J.Kind.ParameterizedType) return undefined;
292
+ const parameterized = type.objectType as J.ParameterizedType;
293
+ const query = parameterized.typeParameters?.elements;
294
+ if (!isIdentifier(parameterized.class) || parameterized.class.simpleName !== 'ConstructorParameters' ||
295
+ query?.length !== 1 || query[0].element.kind !== JS.Kind.TypeQuery) {
296
+ return undefined;
297
+ }
298
+ return (query[0].element as JS.TypeQuery).typeExpression;
299
+ }
300
+
301
+ /** The `proxy` an options literal names, if it is one. */
302
+ function literalProxy(options: J): J | undefined {
303
+ if (!isObjectLiteral(options)) return undefined;
304
+ for (const padded of paddedMembers(options)) {
305
+ const option = classifyOption(padded.element);
306
+ if (option.kind === 'proxy') return option.value;
307
+ }
308
+ return undefined;
309
+ }
310
+
185
311
  export class ReplaceHpagent extends Recipe {
186
312
  readonly name = "org.openrewrite.node.migrate.hpagent.replace-hpagent";
187
313
  readonly displayName = "Replace `hpagent` with `https-proxy-agent`";
@@ -192,21 +318,29 @@ export class ReplaceHpagent extends Recipe {
192
318
  "`https:` target, so one class serves both. `proxyRequestOptions` members move up into the options object, " +
193
319
  "where `https-proxy-agent` applies them to the connection to the proxy. hpagent accepted a missing proxy until " +
194
320
  "a request needed it, but `https-proxy-agent` reads it in the constructor, so in JavaScript a proxy that may be " +
195
- "missing, such as `process.env.HTTP_PROXY`, guards the construction: `proxy ? new HttpsProxyAgent(proxy) : undefined`. " +
196
- "Calls whose options aren't an object " +
197
- "literal, or that set a TLS option for the destination handshake (`ca`, `rejectUnauthorized`, `cert`, ...), " +
198
- "are left alone: `https-proxy-agent` would apply those to the proxy hop instead. " +
199
- "`FindHpagentManualMigrations` reports every usage left behind.";
321
+ "missing, such as `process.env.HTTP_PROXY`, guards the construction: `proxy ? new HttpsProxyAgent(proxy) : undefined`.\n\n" +
322
+ "A construction that can't take the two-argument form — options that aren't an object literal, a spread " +
323
+ "that can't be checked, or a TLS option for the destination handshake (`ca`, `rejectUnauthorized`, `cert`, " +
324
+ "...), which `https-proxy-agent` would apply to the proxy hop — calls a `createProxyAgent` adapter written " +
325
+ "into the same file instead. It takes hpagent's options object as it was, gives the proxy hop what hpagent " +
326
+ "gave it, and puts every other option on the agent's per-request options, which is where hpagent's " +
327
+ "`https.Agent` kept them for the destination. hpagent's option types become the adapter's own " +
328
+ "`ProxyAgentOptions`, `ConstructorParameters<typeof HttpProxyAgent>[0]` included, and `ProxyAgentRequestOptions` " +
329
+ "a `ProxyRequestOptions` of its own; `instanceof` checks and type references move to the new class. A default " +
330
+ "import becomes a namespace import, since `https-proxy-agent` has no default export; a block's " +
331
+ "`const {...} = require('hpagent')`, and `({...} = require('hpagent'))` into a `let`, move like a top-level one; " +
332
+ "`jest.mock`/`vi.mock` and `spyOn` follow the module in a file whose hpagent bindings all moved; and a " +
333
+ "`declare module 'hpagent'` is deleted. `FindHpagentManualMigrations` reports every usage left behind.";
200
334
  readonly tags = ["hpagent", "https-proxy-agent"];
201
335
 
202
336
  @Option({
203
337
  displayName: "Migrate spread options",
204
- description: "Also migrate options that spread another object, as in `{...shared, proxy: url}`, passing the " +
205
- "spread through to the new options object. Left off by default: the spread may itself carry a " +
206
- "`proxyRequestOptions`, which has to be flattened, or a TLS option for the destination handshake, which " +
207
- "`https-proxy-agent` would apply to the proxy connection instead. Enable it once the spread objects in " +
208
- "the codebase are known to hold plain agent options. A spread that resolves to a literal of plain " +
209
- "agent options in the same file is migrated either way, since there it can be checked rather than assumed.",
338
+ description: "Rewrite options that spread another object, as in `{...shared, proxy: url}`, to the " +
339
+ "two-argument form, passing the spread through to the new options object, instead of calling the " +
340
+ "adapter. Left off by default: the spread may itself carry a `proxyRequestOptions`, which has to be " +
341
+ "flattened, or a TLS option for the destination handshake, which `https-proxy-agent` would apply to the " +
342
+ "proxy connection instead. A spread that resolves to a literal of plain agent options in the same file " +
343
+ "takes the two-argument form either way, since there it can be checked rather than assumed.",
210
344
  required: false
211
345
  })
212
346
  migrateSpreadOptions?: boolean;
@@ -218,82 +352,114 @@ export class ReplaceHpagent extends Recipe {
218
352
  async editor(): Promise<TreeVisitor<any, ExecutionContext>> {
219
353
  const migrateSpreadOptions = this.migrateSpreadOptions ?? false;
220
354
  return new class extends JavaScriptVisitor<ExecutionContext> {
355
+ private found: HpagentBindings = emptyHpagentBindings();
221
356
  private bindings: ModuleBindings = new Map();
222
- /** Bindings whose every construction site was rewritten, so the import can move with them. */
357
+ /** Bindings with no site left behind, so the import can move with them. */
223
358
  private migrated = new Set<string>();
224
359
  /** Bindings with a site left behind, which keeps their hpagent import in place. */
225
360
  private stranded = new Set<string>();
226
361
  /** What each binding is called once migrated, which the rewritten call sites have to use. */
227
362
  private targetLocals = new Map<string, string>();
228
- /** Bindings a `require` introduces, which `maybeRebind` leaves alone; this visitor moves them itself. */
229
- private requireBound = new Set<string>();
230
363
  /** Set for the survey pass, which only records what can move and changes nothing. */
231
364
  private surveying = false;
232
365
  /** Class bindings folded into another one, whose own binding is dropped once its sites point at that. */
233
366
  private merged = new Set<string>();
234
367
  /** Bindings that share one name after the move, and so move together or not at all. */
235
368
  private folds: string[][] = [];
236
- /** Bindings named in a type position, which the move has to carry along with the calls. */
237
- private typeUses = new Set<string>();
369
+ /** Bindings with a construction the adapter takes, any of which can name the class for it. */
370
+ private adapterLocals = new Set<string>();
371
+ /** Bindings something still names as a value; one named only in types is unbound rather than moved. */
372
+ private referenced = new Set<string>();
373
+ /** The file's own bindings of the whole `http` and `https` modules, to spell a type through. */
374
+ private builtinLocals = new Map<string, string>();
375
+ /** Type arms this rewrote, which a union may drop as duplicates of another arm. */
376
+ private producedTypes = new Set<string>();
377
+ private adapterName = ADAPTER_FUNCTION;
378
+ private typeName = ADAPTER_TYPE;
379
+ private requestTypeName = REQUEST_TYPE;
380
+ private adapterNeeded = false;
381
+ private typeNeeded = false;
382
+ private requestTypeNeeded = false;
383
+ /** Whole-module imports rewritten where they stand, which the rebinding after the visit leaves alone. */
384
+ private movedInPlace = new Set<string>();
238
385
 
239
386
  protected override async visitJsCompilationUnit(cu: JS.CompilationUnit, p: ExecutionContext): Promise<J | undefined> {
240
- this.bindings = bindingsOf(cu, HPAGENT_MODULES);
387
+ // Types declared for hpagent go with it; without the module there is nothing for them to describe.
388
+ if (!this.surveying && cu.statements.some(padded => isAmbientHpagent(padded.element))) {
389
+ cu = {...cu, statements: cu.statements.filter(padded => !isAmbientHpagent(padded.element))};
390
+ }
391
+ this.found = hpagentBindings(cu);
392
+ this.bindings = this.found.bindings;
241
393
  this.migrated = new Set();
242
394
  this.stranded = new Set();
243
395
  this.targetLocals = new Map();
244
396
  this.merged = new Set();
245
397
  this.folds = [];
246
- this.typeUses = new Set();
247
- this.requireBound = requireBoundNames(cu);
398
+ this.adapterLocals = new Set();
399
+ this.referenced = new Set();
400
+ this.producedTypes = new Set();
401
+ this.builtinLocals = new Map();
402
+ this.adapterNeeded = false;
403
+ this.typeNeeded = false;
404
+ this.requestTypeNeeded = false;
405
+ this.movedInPlace = new Set();
248
406
  if (this.bindings.size === 0) return cu;
249
407
  this.planNames(cu);
408
+ for (const module of ['http', 'https']) {
409
+ const whole = [...bindingsOf(cu, new Set([module, `node:${module}`]))].find(([, member]) => member === undefined);
410
+ if (whole) this.builtinLocals.set(module, whole[0]);
411
+ }
412
+ const used = namesUsedIn(cu);
413
+ this.adapterName = deconflict(ADAPTER_FUNCTION, name => used.has(name));
414
+ this.typeName = deconflict(ADAPTER_TYPE, name => used.has(name));
415
+ this.requestTypeName = deconflict(REQUEST_TYPE, name => used.has(name));
250
416
 
251
- // A binding moves only if every one of its constructions can: those left behind keep hpagent's
417
+ // A binding moves only if every one of its sites can: one left behind keeps hpagent's
252
418
  // options-object constructor, so rewriting any site of theirs would break it. Hence a survey
253
419
  // pass first, whose result is discarded, and only then the pass that edits.
254
420
  this.surveying = true;
255
421
  await super.visitJsCompilationUnit(cu, p);
256
422
  this.surveying = false;
257
- // A binding named only in a type position has no construction to migrate, but its
258
- // type reference moves, so it counts as migrating for the fold below.
259
- for (const local of this.typeUses) this.migrated.add(local);
423
+ // Every site of a binding not stranded can move, including one with no construction at all,
424
+ // named only as a type or by `instanceof`, or not used anywhere.
425
+ for (const local of this.targetLocals.keys()) this.migrated.add(local);
260
426
  // Folded bindings end up under one name, so every one of them has to move. One left
261
- // behind — a site of its own that can't migrate, or nothing to migrate at all —
262
- // keeps its specifier on hpagent, and the shared name would then denote hpagent's
263
- // class while the rewritten calls pass the proxy positionally. Refusing the whole
264
- // fold leaves each binding as it was.
427
+ // behind keeps its specifier on hpagent, and the shared name would then denote hpagent's
428
+ // class while the rewritten calls pass the proxy positionally. Refusing the whole fold
429
+ // leaves each binding as it was.
265
430
  for (const fold of this.folds) {
266
- if (fold.some(local => this.stranded.has(local) || !this.migrated.has(local))) {
431
+ // A use of any member is a use of the binding they all end up as.
432
+ if (fold.some(local => this.referenced.has(local))) for (const local of fold) this.referenced.add(local);
433
+ if (fold.some(local => this.stranded.has(local))) {
267
434
  for (const local of fold) this.stranded.add(local);
268
435
  }
269
436
  }
270
- if ([...this.migrated].every(local => this.stranded.has(local))) return cu;
437
+ const optionTypes = [...this.bindings.values()].some(member => member !== undefined && isOptionType(member));
438
+ if ([...this.migrated].every(local => this.stranded.has(local)) && !optionTypes) return cu;
271
439
 
272
- const result = await super.visitJsCompilationUnit(cu, p) as JS.CompilationUnit;
440
+ let result = await super.visitJsCompilationUnit(cu, p) as JS.CompilationUnit;
441
+ if (this.adapterNeeded || this.typeNeeded || this.requestTypeNeeded) result = await this.withAdapter(result, p);
273
442
  this.rebindMigrated();
274
- return this.rewriteRequires(result);
443
+ return result;
275
444
  }
276
445
 
277
446
  /**
278
- * What each hpagent binding will be called after the move. An unaliased class binding takes the
279
- * new module's own name for it; an alias, and a binding of the whole module, keep theirs. A name
280
- * the file already uses would collide, so those bindings are left where they are.
447
+ * What each hpagent class binding will be called after the move. An unaliased class binding takes
448
+ * the new module's own name for it, deconflicted against the file's own; an alias, and a binding
449
+ * of the whole module, keep theirs.
281
450
  */
282
451
  private planNames(cu: JS.CompilationUnit): void {
283
- const declared = namesDeclaredIn(cu);
452
+ const used = namesUsedIn(cu);
284
453
  // Both agent classes become the one class, so a file binding both folds them together
285
454
  // rather than colliding: `https-proxy-agent` tunnels either way and picks TLS per request.
286
- const classes = [...this.bindings].filter(([, member]) => member !== undefined && HPAGENT_CLASSES.has(member));
287
- const foldable = new Set(classes.filter(([local, member]) => local === member).map(([local]) => local));
455
+ const movable = [...this.bindings].filter(([, member]) => member === undefined || HPAGENT_CLASSES.has(member));
456
+ const foldable = new Set(movable.filter(([local, member]) => local === member).map(([local]) => local));
288
457
 
458
+ const targetClass = deconflict(TARGET_CLASS, name => used.has(name) && !foldable.has(name));
289
459
  const targets = new Map<string, string[]>();
290
- for (const [local, member] of this.bindings) {
291
- const target = member !== undefined && local === member ? TARGET_CLASS : local;
292
- // A name the file uses for something else leaves this binding where it is.
293
- if (target !== local && declared.has(target) && !foldable.has(target)) {
294
- this.stranded.add(local);
295
- continue;
296
- }
460
+ for (const [local, member] of movable) {
461
+ // A name assigned into a `let` keeps it, since the declaration and every use would otherwise have to follow.
462
+ const target = member !== undefined && local === member && !this.found.assigned.has(local) ? targetClass : local;
297
463
  this.targetLocals.set(local, target);
298
464
  targets.set(target, [...(targets.get(target) ?? []), local]);
299
465
  }
@@ -314,27 +480,86 @@ export class ReplaceHpagent extends Recipe {
314
480
  /**
315
481
  * Moves the imports of bindings whose every site was rewritten. A named binding of either hpagent
316
482
  * class becomes `https-proxy-agent`'s `HttpsProxyAgent`; a whole-module binding moves module and
317
- * keeps its name, since the new module exports that same class name.
483
+ * keeps its name, since the new module exports that same class name. hpagent's option types are
484
+ * dropped once the adapter's type has taken every reference to them.
318
485
  */
319
486
  private rebindMigrated(): void {
320
487
  for (const local of this.migrated) {
321
- if (this.stranded.has(local) || this.requireBound.has(local)) continue;
488
+ if (this.stranded.has(local) || this.found.requireBound.has(local) || this.movedInPlace.has(local)) continue;
322
489
  const member = this.bindings.get(local);
323
490
  if (this.merged.has(local)) {
324
491
  // Its sites now name the surviving binding, so this specifier is unused.
325
492
  maybeUnbind(this, {module: 'hpagent', member});
326
493
  continue;
327
494
  }
495
+ if (!this.referenced.has(local)) {
496
+ // Named only in types, which are builtin agents now.
497
+ maybeUnbind(this, member === undefined ? {module: 'hpagent'} : {module: 'hpagent', member});
498
+ continue;
499
+ }
328
500
  if (member === undefined) {
329
501
  maybeRebind(this, {from: {module: 'hpagent'}, to: {module: TARGET_MODULE}});
330
502
  } else {
503
+ const target = this.targetLocals.get(local)!;
331
504
  maybeRebind(this, {
332
505
  from: {module: 'hpagent', member},
333
506
  // An unaliased `HttpProxyAgent` takes the new member's name; an alias the file chose survives.
334
- to: {module: TARGET_MODULE, member: TARGET_CLASS, ...(local === member ? {} : {alias: local})}
507
+ to: {module: TARGET_MODULE, member: TARGET_CLASS, ...(target === TARGET_CLASS ? {} : {alias: target})}
335
508
  });
336
509
  }
337
510
  }
511
+ for (const [local, member] of this.bindings) {
512
+ if (member !== undefined && isOptionType(member) && !this.found.requireBound.has(local)) {
513
+ maybeUnbind(this, {module: 'hpagent', member});
514
+ }
515
+ }
516
+ }
517
+
518
+ /**
519
+ * `import hpagent from 'hpagent'` or `import * as hpagent from 'hpagent'`, rewritten where it stands to
520
+ * `import * as hpagent from 'https-proxy-agent'`: the new module has no default export, so the whole
521
+ * module is its namespace, and rewriting in place keeps the import where and how the file wrote it.
522
+ */
523
+ protected override async visitImportDeclaration(jsImport: JS.Import, p: ExecutionContext): Promise<J | undefined> {
524
+ const imp = await super.visitImportDeclaration(jsImport, p) as JS.Import;
525
+ const clause = imp?.importClause;
526
+ if (this.surveying || imp?.kind !== JS.Kind.Import || !clause || clause.typeOnly ||
527
+ !HPAGENT_MODULES.has(importModule(imp) ?? '')) {
528
+ return imp;
529
+ }
530
+ const named = clause.namedBindings;
531
+ const namespace = named?.kind === JS.Kind.Alias ? named as JS.Alias : undefined;
532
+ const defaultName = !named ? clause.name?.element : undefined;
533
+ const local = defaultName ?? (!clause.name && namespace && isIdentifier(namespace.alias) ? namespace.alias : undefined);
534
+ if (!local || !this.migrated.has(local.simpleName) || this.stranded.has(local.simpleName) ||
535
+ !this.referenced.has(local.simpleName)) {
536
+ return imp;
537
+ }
538
+ this.movedInPlace.add(local.simpleName);
539
+ const moved = {...imp, moduleSpecifier: {...imp.moduleSpecifier!, element: retargeted(imp.moduleSpecifier!.element as J.Literal)}};
540
+ if (!defaultName) return moved;
541
+ const star = {kind: J.Kind.Identifier, id: randomId(), prefix: emptySpace, markers: emptyMarkers, annotations: [], simpleName: '*'};
542
+ const alias = {
543
+ kind: JS.Kind.Alias, id: randomId(), prefix: defaultName.prefix, markers: emptyMarkers,
544
+ propertyName: rightPadded(star, space(' ')), alias: {...defaultName, prefix: space(' ')}
545
+ };
546
+ return {
547
+ ...moved,
548
+ importClause: {...clause, name: undefined, namedBindings: alias},
549
+ // The space before `from` sat after the default name.
550
+ moduleSpecifier: {...moved.moduleSpecifier, before: clause.name!.after}
551
+ } as unknown as JS.Import;
552
+ }
553
+
554
+ /** Whether `name` here is a binding of a whole hpagent module rather than something nearer. */
555
+ private isWholeModule(name: string): boolean {
556
+ return this.bindings.has(name) && this.bindings.get(name) === undefined && resolvesToBinding(this.cursor, name, this.found);
557
+ }
558
+
559
+ /** The hpagent class binding `name` is here, or undefined. */
560
+ private isClass(name: string): boolean {
561
+ const member = this.bindings.get(name);
562
+ return member !== undefined && HPAGENT_CLASSES.has(member) && resolvesToBinding(this.cursor, name, this.found);
338
563
  }
339
564
 
340
565
  override async visitNewClass(newClass: J.NewClass, p: ExecutionContext): Promise<J | undefined> {
@@ -344,90 +569,255 @@ export class ReplaceHpagent extends Recipe {
344
569
  if (!site) return n;
345
570
 
346
571
  const rewrite = this.rewriteOf(n);
572
+ const adaptable = !rewrite && constructionArgument(n) !== undefined;
347
573
  if (this.surveying) {
348
- (rewrite ? this.migrated : this.stranded).add(site.local);
574
+ (rewrite || adaptable ? this.migrated : this.stranded).add(site.local);
575
+ if (rewrite || adaptable) this.referenced.add(site.local);
576
+ if (adaptable) this.adapterLocals.add(site.local);
349
577
  return n;
350
578
  }
351
- if (!rewrite || this.stranded.has(site.local)) return n;
352
- const migrated = this.applyRewrite(n, site, rewrite);
579
+ if (this.stranded.has(site.local) || !(rewrite || adaptable)) return n;
580
+ const migrated = rewrite ? this.applyRewrite(n, site, rewrite) : this.adapterCall(n);
581
+ const proxy = rewrite ? rewrite.proxy : literalProxy(constructionArgument(n)!);
353
582
  // Constructing with a missing proxy used to succeed and now throws, as when a module builds its
354
583
  // agent from an environment variable a test run doesn't set, so the construction is skipped then.
355
- return isJavaScript(compilationUnitOf(this)!.sourcePath) && !alwaysDefined(rewrite.proxy) && readsOnly(rewrite.proxy)
356
- ? whenDefined(rewrite.proxy, migrated)
584
+ return proxy && isJavaScript(compilationUnitOf(this)!.sourcePath) && !alwaysDefined(proxy) && readsOnly(proxy)
585
+ ? whenDefined(proxy, migrated)
357
586
  : migrated;
358
587
  }
359
588
 
589
+ /** `createProxyAgent(options)` in place of `new HttpsProxyAgent(options)`, with the arguments as written. */
590
+ private adapterCall(n: J.NewClass): J.MethodInvocation {
591
+ this.adapterNeeded = true;
592
+ this.typeNeeded = true;
593
+ return {
594
+ kind: J.Kind.MethodInvocation,
595
+ id: randomId(),
596
+ prefix: n.prefix,
597
+ markers: emptyMarkers,
598
+ name: {
599
+ kind: J.Kind.Identifier, id: randomId(), prefix: emptySpace, markers: emptyMarkers,
600
+ annotations: [], simpleName: this.adapterName
601
+ } as J.Identifier,
602
+ arguments: n.arguments,
603
+ } as unknown as J.MethodInvocation;
604
+ }
605
+
606
+ /** `x instanceof HttpProxyAgent`, which tests for the one class both hpagent classes became. */
607
+ override async visitInstanceOf(instanceOf: J.InstanceOf, p: ExecutionContext): Promise<J | undefined> {
608
+ const i = await super.visitInstanceOf(instanceOf, p) as J.InstanceOf;
609
+ if (i?.kind !== J.Kind.InstanceOf) return i;
610
+ const site = this.hpagentSite(i.class);
611
+ if (!site) return i;
612
+ if (this.surveying) {
613
+ this.referenced.add(site.local);
614
+ return i;
615
+ }
616
+ return this.stranded.has(site.local) ? i : {...i, class: this.migratedCallee(i.class, site)} as J.InstanceOf;
617
+ }
618
+
619
+ override async visitIdentifier(identifier: J.Identifier, p: ExecutionContext): Promise<J | undefined> {
620
+ const id = await super.visitIdentifier(identifier, p) as J.Identifier;
621
+ if (id?.kind !== J.Kind.Identifier || !this.bindings.has(id.simpleName)) return id;
622
+ if (this.found.assignmentTargets.has(identifier.id)) return id;
623
+ if (!isReference(this.cursor, identifier) || !resolvesToBinding(this.cursor, id.simpleName, this.found)) return id;
624
+ const member = this.bindings.get(id.simpleName);
625
+ if (member !== undefined && isOptionType(member)) return this.optionType(id);
626
+ if (member !== undefined && !HPAGENT_CLASSES.has(member)) return id;
627
+ if (member !== undefined && (inTypePosition(this.cursor) || this.found.typeOnly.has(id.simpleName))) {
628
+ return this.typeReference(id, id.simpleName);
629
+ }
630
+ if (member === undefined && this.isSpiedModule(id)) {
631
+ if (this.surveying) this.referenced.add(id.simpleName);
632
+ return id;
633
+ }
634
+ // A class or module passed around as a value is constructed somewhere this can't see.
635
+ if (this.surveying && isValueUse(this.cursor)) this.stranded.add(id.simpleName);
636
+ return id;
637
+ }
638
+
639
+ /** The adapter's type for one of hpagent's option types, at a site that names it. */
640
+ private optionType(type: J): J {
641
+ if (this.surveying) return type;
642
+ const request = type.kind === J.Kind.FieldAccess
643
+ ? (type as J.FieldAccess).name.element.simpleName === 'ProxyAgentRequestOptions'
644
+ : this.bindings.get((type as J.Identifier).simpleName) === 'ProxyAgentRequestOptions';
645
+ if (request) this.requestTypeNeeded = true;
646
+ else this.typeNeeded = true;
647
+ return {
648
+ kind: J.Kind.Identifier, id: randomId(), prefix: type.prefix, markers: emptyMarkers,
649
+ annotations: [], simpleName: request ? this.requestTypeName : this.typeName
650
+ } as J.Identifier;
651
+ }
652
+
653
+ /** Whether `id` is the module `jest.spyOn(module, 'HttpsProxyAgent')` reads a class off. */
654
+ private isSpiedModule(id: J.Identifier): boolean {
655
+ const parent = this.cursor.parentTree()?.value as J | undefined;
656
+ return parent?.kind === J.Kind.MethodInvocation && spiedClass(parent as J.MethodInvocation) !== undefined &&
657
+ (parent as J.MethodInvocation).arguments.elements[0]?.element === this.cursor.value;
658
+ }
659
+
660
+ /** `hpagent.HttpProxyAgent` or `hpagent.HttpsProxyAgentOptions` through a binding of the whole module. */
661
+ override async visitFieldAccess(fieldAccess: J.FieldAccess, p: ExecutionContext): Promise<J | undefined> {
662
+ const fa = await super.visitFieldAccess(fieldAccess, p) as J.FieldAccess;
663
+ if (fa?.kind !== J.Kind.FieldAccess || !isIdentifier(fa.target) || !this.isWholeModule(fa.target.simpleName)) return fa;
664
+ const local = fa.target.simpleName;
665
+ const name = fa.name.element.simpleName;
666
+ if (isOptionType(name) && inTypePosition(this.cursor)) return this.optionType(fa);
667
+ if (!HPAGENT_CLASSES.has(name)) return fa;
668
+ if (inTypePosition(this.cursor) || this.found.typeOnly.has(local)) return this.typeReference(fa, local);
669
+ if (this.surveying && isValueUse(this.cursor)) this.stranded.add(local);
670
+ return fa;
671
+ }
672
+
360
673
  /**
361
- * A union of types, where an hpagent class becomes `HttpsProxyAgent<string>`. Both classes
362
- * collapse onto the same type, so a union naming both keeps one of them.
674
+ * The builtin agent for a type naming an hpagent class, which is all hpagent declared it as. `typeof`
675
+ * names the constructor instead, and so becomes `https-proxy-agent`'s class.
676
+ */
677
+ private typeReference(type: J.Identifier | J.FieldAccess, local: string): J {
678
+ const typeQuery = this.cursor.parentTree()?.value?.kind === JS.Kind.TypeQuery;
679
+ if (this.surveying) {
680
+ if (typeQuery) this.referenced.add(local);
681
+ return type;
682
+ }
683
+ if (this.stranded.has(local)) return type;
684
+ const memberAccess = type.kind === J.Kind.FieldAccess ? type : undefined;
685
+ if (typeQuery) return this.migratedCallee(type, {local, memberAccess});
686
+ const member = memberAccess ? memberAccess.name.element.simpleName : this.bindings.get(local)!;
687
+ const module = builtinAgentModule(member);
688
+ const agent = builtinAgentType(this.builtinLocals.get(module), builtinSpecifier(compilationUnitOf(this)!, module), type.prefix);
689
+ this.producedTypes.add(agent.id);
690
+ return agent;
691
+ }
692
+
693
+ /**
694
+ * A union of types, whose hpagent arms have already become builtin agents, which may now repeat an arm
695
+ * the union already had, as `http.Agent | HttpProxyAgent` does. A repeat this produced is dropped.
363
696
  */
364
697
  protected override async visitUnion(union: JS.Union, p: ExecutionContext): Promise<J | undefined> {
365
698
  const u = await super.visitUnion(union, p) as JS.Union;
366
- if (u?.kind !== JS.Kind.Union) return u;
367
- let changed = false;
699
+ if (u?.kind !== JS.Kind.Union || this.surveying) return u;
368
700
  const seen = new Set<string>();
369
701
  const types: J.RightPadded<any>[] = [];
370
702
  for (const padded of u.types) {
371
- const replacement = this.migratedTypeReference(padded.element);
372
- if (replacement) changed = true;
373
- const element = replacement ?? padded.element;
374
- const key = typeKey(element);
703
+ const key = typeKey(padded.element);
375
704
  // A duplicate arm says nothing the first one didn't.
376
- if (key !== undefined && seen.has(key)) {
377
- changed = true;
378
- continue;
379
- }
705
+ if (key !== undefined && this.producedTypes.has(padded.element.id) && seen.has(key)) continue;
380
706
  if (key !== undefined) seen.add(key);
381
- types.push(replacement ? {...padded, element: replacement} : padded);
707
+ types.push(padded);
382
708
  }
383
- if (!changed) return u;
709
+ if (types.length === u.types.length) return u;
384
710
  // The space before `|` lives on each arm's right padding, so dropping the last arm
385
711
  // has to hand its padding to whichever arm is last now.
386
712
  types[types.length - 1] = {...types[types.length - 1], after: u.types[u.types.length - 1].after};
387
713
  return {...u, types} as JS.Union;
388
714
  }
389
715
 
390
- /** A type annotation naming an hpagent class on its own, outside any union. */
391
- protected override async visitTypeInfo(typeInfo: JS.TypeInfo, p: ExecutionContext): Promise<J | undefined> {
392
- const t = await super.visitTypeInfo(typeInfo, p) as JS.TypeInfo;
393
- if (t?.kind !== JS.Kind.TypeInfo) return t;
394
- const replacement = this.migratedTypeReference(t.typeIdentifier as J);
395
- return replacement ? {...t, typeIdentifier: replacement} as JS.TypeInfo : t;
716
+ /** `ConstructorParameters<typeof HttpProxyAgent>[0]`, hpagent's options object, which the adapter now takes. */
717
+ protected override async visitIndexedAccessType(indexedAccessType: JS.IndexedAccessType, p: ExecutionContext): Promise<J | undefined> {
718
+ const operand = constructorOptionsOf(indexedAccessType);
719
+ const site = operand && this.hpagentSite(operand);
720
+ if (!site || this.surveying || this.stranded.has(site.local)) return super.visitIndexedAccessType(indexedAccessType, p);
721
+ this.typeNeeded = true;
722
+ return {
723
+ kind: J.Kind.Identifier, id: randomId(), prefix: indexedAccessType.prefix, markers: emptyMarkers,
724
+ annotations: [], simpleName: this.typeName
725
+ } as J.Identifier;
396
726
  }
397
727
 
398
- /** `HttpsProxyAgent<string>` for a type reference to an hpagent class, or undefined for anything else. */
399
- private migratedTypeReference(type: J): J.ParameterizedType | undefined {
400
- if (!isIdentifier(type)) return undefined;
401
- const member = this.bindings.get(type.simpleName);
402
- if (member === undefined || !HPAGENT_CLASSES.has(member) || !isModuleScoped(this.cursor, type.simpleName)) {
403
- return undefined;
728
+ /** `({HttpsProxyAgent} = require('hpagent'))` into a `let`, once every name it assigns has moved. */
729
+ override async visitAssignment(assignment: J.Assignment, p: ExecutionContext): Promise<J | undefined> {
730
+ const a = await super.visitAssignment(assignment, p) as J.Assignment;
731
+ if (this.surveying || a?.kind !== J.Kind.Assignment || !isHpagentRequire(a.assignment.element) ||
732
+ !isObjectLiteral(a.variable)) {
733
+ return a;
404
734
  }
405
- if (this.surveying) {
406
- this.typeUses.add(type.simpleName);
407
- return undefined;
735
+ const pattern = a.variable;
736
+ const members = objectMembers(pattern) as JS.PropertyAssignment[];
737
+ const locals = members.map(m => (m.initializer ?? m.name.element) as J.Identifier);
738
+ if (!locals.every(l => this.found.assigned.has(l.simpleName) && !this.stranded.has(l.simpleName))) return a;
739
+ const statements = pattern.body!.statements.map(padded => {
740
+ const prop = padded.element as JS.PropertyAssignment;
741
+ if (prop.kind !== JS.Kind.PropertyAssignment || propertyName(prop) === TARGET_CLASS) return padded;
742
+ const name = prop.name.element as J.Identifier;
743
+ // `{HttpProxyAgent}` keeps its local, so it reads the new class off as `{HttpsProxyAgent: HttpProxyAgent}`.
744
+ const element = prop.initializer
745
+ ? {...prop, name: {...prop.name, element: {...name, simpleName: TARGET_CLASS}}}
746
+ : {
747
+ ...prop,
748
+ name: {...prop.name, element: {...name, id: randomId(), simpleName: TARGET_CLASS}},
749
+ assigmentToken: JS.PropertyAssignment.Token.Colon,
750
+ initializer: {...name, prefix: space(' ')}
751
+ };
752
+ return {...padded, element};
753
+ });
754
+ return {
755
+ ...a,
756
+ variable: {...pattern, body: {...pattern.body!, statements}},
757
+ assignment: {...a.assignment, element: movedRequireCall(a.assignment.element)}
758
+ } as J.Assignment;
759
+ }
760
+
761
+ /**
762
+ * `jest.mock('hpagent')` and `vi.mock('hpagent', ...)` replace the module the code under test now imports
763
+ * instead, once this file's own hpagent bindings have all moved; `jest.spyOn(hpagent, 'HttpProxyAgent')`
764
+ * spies on the one class both became.
765
+ */
766
+ override async visitMethodInvocation(method: J.MethodInvocation, p: ExecutionContext): Promise<J | undefined> {
767
+ const m = await super.visitMethodInvocation(method, p) as J.MethodInvocation;
768
+ if (this.surveying || m?.kind !== J.Kind.MethodInvocation) return m;
769
+ const args = m.arguments.elements;
770
+ const first = args[0]?.element;
771
+ if (isMockCall(m) && first?.kind === J.Kind.Literal && HPAGENT_MODULES.has(stringLiteral(first) ?? '') &&
772
+ [...this.bindings.keys()].every(local => !this.stranded.has(local))) {
773
+ return {...m, arguments: {...m.arguments, elements: [{...args[0], element: retargeted(first as J.Literal)}, ...args.slice(1)]}} as J.MethodInvocation;
774
+ }
775
+ const spied = spiedClass(m);
776
+ if (spied && spied !== TARGET_CLASS && first && isIdentifier(first) && this.bindings.has(first.simpleName) &&
777
+ this.bindings.get(first.simpleName) === undefined && !this.stranded.has(first.simpleName)) {
778
+ const literal = args[1].element as J.Literal;
779
+ const quote = (literal.valueSource ?? "'").charAt(0);
780
+ const renamed = {...literal, value: TARGET_CLASS, valueSource: `${quote}${TARGET_CLASS}${quote}`};
781
+ return {...m, arguments: {...m.arguments, elements: [args[0], {...args[1], element: renamed}, ...args.slice(2)]}} as J.MethodInvocation;
408
782
  }
409
- if (this.stranded.has(type.simpleName)) return undefined;
410
- const local = this.targetLocals.get(type.simpleName) ?? type.simpleName;
411
- return targetTypeReference(local, type.prefix);
783
+ return m;
784
+ }
785
+
786
+ /** A `require` of hpagent, at the top level or in a block, once every name it binds has moved. */
787
+ override async visitVariableDeclarations(varDecls: J.VariableDeclarations, p: ExecutionContext): Promise<J | undefined> {
788
+ const v = await super.visitVariableDeclarations(varDecls, p) as J.VariableDeclarations;
789
+ if (this.surveying || v?.kind !== J.Kind.VariableDeclarations) return v;
790
+ let changed = false;
791
+ const variables = v.variables.map(padded => {
792
+ const variable = padded.element;
793
+ const initializer = variable.initializer?.element;
794
+ if (!isHpagentRequire(initializer)) return padded;
795
+ const pattern = variable.name as J;
796
+ const names = isIdentifier(pattern) ? [pattern.simpleName] : bindingNames(pattern).map(b => b.name);
797
+ // One specifier serves every name the declaration binds, so it moves once they all have.
798
+ if (names.length === 0 || names.some(n => !this.migrated.has(n) || this.stranded.has(n))) return padded;
799
+ changed = true;
800
+ return {
801
+ ...padded,
802
+ element: {
803
+ ...variable,
804
+ name: renameBindings(pattern, this.targetLocals, this.merged),
805
+ initializer: {...variable.initializer!, element: movedRequireCall(initializer!)}
806
+ }
807
+ };
808
+ });
809
+ return changed ? {...v, variables} as J.VariableDeclarations : v;
412
810
  }
413
811
 
414
- /** The local binding a `new` site constructs through, and the member name it names on it. */
812
+ /** The local binding a `new` or `instanceof` site names the class through, and the member it reads. */
415
813
  private hpagentSite(clazz: J): {local: string; memberAccess?: J.FieldAccess} | undefined {
416
- if (isIdentifier(clazz)) {
417
- const member = this.bindings.get(clazz.simpleName);
418
- return member !== undefined && HPAGENT_CLASSES.has(member) && isModuleScoped(this.cursor, clazz.simpleName)
419
- ? {local: clazz.simpleName}
420
- : undefined;
421
- }
814
+ if (isIdentifier(clazz)) return this.isClass(clazz.simpleName) ? {local: clazz.simpleName} : undefined;
422
815
  if (clazz.kind !== J.Kind.FieldAccess) return undefined;
423
816
  const access = clazz as J.FieldAccess;
424
817
  const target = access.target;
425
818
  if (!isIdentifier(target) || !HPAGENT_CLASSES.has(access.name.element.simpleName)) return undefined;
426
819
  // Only a binding of the whole module has the classes as its members.
427
- return this.bindings.has(target.simpleName) && this.bindings.get(target.simpleName) === undefined &&
428
- isModuleScoped(this.cursor, target.simpleName)
429
- ? {local: target.simpleName, memberAccess: access}
430
- : undefined;
820
+ return this.isWholeModule(target.simpleName) ? {local: target.simpleName, memberAccess: access} : undefined;
431
821
  }
432
822
 
433
823
  /** The migrated argument list, or undefined where the options can't be split into one. */
@@ -471,7 +861,7 @@ export class ReplaceHpagent extends Recipe {
471
861
  }
472
862
  }
473
863
  if (!proxy) return undefined;
474
- // A name given twice would change which value wins; leave that to a human.
864
+ // A name given twice would change which value wins; leave that to the adapter.
475
865
  const names = members
476
866
  .filter(m => m.element.kind === JS.Kind.PropertyAssignment)
477
867
  .map(m => propertyName(m.element as JS.PropertyAssignment));
@@ -479,53 +869,6 @@ export class ReplaceHpagent extends Recipe {
479
869
  return {proxy, members, literal};
480
870
  }
481
871
 
482
- /**
483
- * Moves the `require`s of bindings whose every site was rewritten: the specifier becomes the new
484
- * module, and an unaliased `HttpProxyAgent` the new module's name for the class. A declaration is
485
- * only touched once every name it binds has migrated, since one specifier serves them all.
486
- */
487
- private rewriteRequires(cu: JS.CompilationUnit): JS.CompilationUnit {
488
- if (this.requireBound.size === 0) return cu;
489
- let changed = false;
490
- const statements = cu.statements.map(padded => {
491
- const names = requireBoundNames({...cu, statements: [padded]} as JS.CompilationUnit);
492
- if (names.size === 0 || [...names].some(n => !this.migrated.has(n) || this.stranded.has(n))) return padded;
493
- const moved = this.movedRequire(padded.element);
494
- if (moved === padded.element) return padded;
495
- changed = true;
496
- return {...padded, element: moved};
497
- });
498
- return changed ? {...cu, statements} : cu;
499
- }
500
-
501
- /** `const {HttpProxyAgent} = require('hpagent')` with both the specifier and the member moved. */
502
- private movedRequire(statement: J): J {
503
- const decls = statement as J.VariableDeclarations;
504
- if (decls?.kind !== J.Kind.VariableDeclarations) return statement;
505
- const variables = decls.variables.map(padded => {
506
- const variable = padded.element;
507
- const initializer = variable.initializer?.element;
508
- if (requiredModule(initializer) === undefined) return padded;
509
- const call = initializer as J.MethodInvocation;
510
- const specifier = call.arguments.elements[0];
511
- const literal = specifier.element as J.Literal;
512
- const quote = (literal.valueSource ?? "'").charAt(0);
513
- const moved: J.Literal = {...literal, value: TARGET_MODULE, valueSource: `${quote}${TARGET_MODULE}${quote}`};
514
- return {
515
- ...padded,
516
- element: {
517
- ...variable,
518
- name: renameBindings(variable.name as J, this.targetLocals),
519
- initializer: {
520
- ...variable.initializer!,
521
- element: {...call, arguments: {...call.arguments, elements: [{...specifier, element: moved}]}}
522
- }
523
- }
524
- };
525
- });
526
- return {...decls, variables} as J;
527
- }
528
-
529
872
  /**
530
873
  * `new X({proxy: p, ...rest})` as `new X(p, {...rest})`: the proxy expression takes the space the
531
874
  * literal opened with, and the literal itself is reused for the second argument so its members keep
@@ -543,17 +886,28 @@ export class ReplaceHpagent extends Recipe {
543
886
  return {...n, class: this.migratedCallee(n.class!, site), arguments: {...n.arguments, elements}} as J.NewClass;
544
887
  }
545
888
 
546
- /** The options literal with `proxy` gone, its first surviving member opening the object as the old one did. */
889
+ /**
890
+ * The options literal with `proxy` gone, its first surviving member opening the object as the old one
891
+ * did and its last closing it as the old one did, trailing comma included.
892
+ */
547
893
  private withMembers(rewrite: Rewrite): J.NewClass {
548
894
  const original = paddedMembers(rewrite.literal);
549
- const members = [...rewrite.members];
895
+ const trailing = (padded: J.RightPadded<J>) => padded.markers.markers.some(m => m.kind === J.Markers.TrailingComma);
896
+ // A member flattened out of `proxyRequestOptions` may close that literal with a comma of its own.
897
+ const members = rewrite.members.map((m, i) => i < rewrite.members.length - 1 && trailing(m)
898
+ ? {...m, markers: {...m.markers, markers: m.markers.markers.filter(k => k.kind !== J.Markers.TrailingComma)}}
899
+ : m);
550
900
  if (members[0] !== original[0]) {
551
901
  members[0] = {...members[0], element: {...members[0].element, prefix: original[0].element.prefix}};
552
902
  }
903
+ const last = original[original.length - 1];
904
+ if (rewrite.members[rewrite.members.length - 1] !== last) {
905
+ members[members.length - 1] = {...members[members.length - 1], after: last.after, markers: last.markers};
906
+ }
553
907
  return {...rewrite.literal, prefix: space(' '), body: {...rewrite.literal.body!, statements: members}} as J.NewClass;
554
908
  }
555
909
 
556
- /** The constructed name after the move: the binding's new local name, or the new class on a namespace. */
910
+ /** The class's name after the move: the binding's new local name, or the new class on a namespace. */
557
911
  private migratedCallee(clazz: J, site: {local: string; memberAccess?: J.FieldAccess}): any {
558
912
  if (site.memberAccess) {
559
913
  const access = site.memberAccess;
@@ -562,6 +916,43 @@ export class ReplaceHpagent extends Recipe {
562
916
  const local = this.targetLocals.get(site.local) ?? site.local;
563
917
  return {...(clazz as J.Identifier), simpleName: local};
564
918
  }
919
+
920
+ /** How the adapter names `https-proxy-agent`'s class: through a binding one of its call sites moved. */
921
+ private agentClass(): string {
922
+ const local = [...this.adapterLocals].find(l => !this.stranded.has(l))!;
923
+ return this.bindings.get(local) === undefined
924
+ ? `${local}.${TARGET_CLASS}`
925
+ : this.targetLocals.get(local) ?? local;
926
+ }
927
+
928
+ /** Appends the adapter and its options type, after the statements that use them. */
929
+ private async withAdapter(cu: JS.CompilationUnit, p: ExecutionContext): Promise<JS.CompilationUnit> {
930
+ const typeScript = !isJavaScript(cu.sourcePath);
931
+ const style = detectCodeStyle(await TreePrinters.print(cu));
932
+ const sources: GeneratedDeclaration[] = [];
933
+ if (this.adapterNeeded) sources.push(adapterSource(this.adapterName, this.typeName, this.agentClass(), typeScript, style));
934
+ if (typeScript) {
935
+ sources.push(...adapterTypeSource(this.typeNeeded ? this.typeName : undefined,
936
+ this.requestTypeNeeded ? this.requestTypeName : undefined, module => builtinSpecifier(cu, module), style));
937
+ }
938
+ // Where the project formats with Prettier, its own configuration lays the declarations out.
939
+ const prettier = findMarker(cu, StyleKind.PrettierStyle) !== undefined;
940
+ let result = cu;
941
+ for (const {doc, code} of sources) {
942
+ // Parsed rather than templated, so the code keeps the layout written for this file's style.
943
+ const parsed = await new JavaScriptParser().parseOnly({
944
+ text: `/*${doc}*/\n${code}\n`, sourcePath: typeScript ? 'adapter.ts' : 'adapter.js'
945
+ }) as JS.CompilationUnit;
946
+ if (parsed.kind !== JS.Kind.CompilationUnit || parsed.statements.length !== 1) continue;
947
+ const statement = parsed.statements[0];
948
+ const parent = new Cursor(result, this.cursor);
949
+ // The parser files a leading comment under the compilation unit rather than the statement.
950
+ let declaration = {...statement.element, prefix: {...parsed.prefix, whitespace: '\n\n'}} as J;
951
+ if (prettier) declaration = await autoFormat(declaration, p, undefined, parent);
952
+ result = {...result, statements: [...result.statements, {...statement, element: declaration}]} as JS.CompilationUnit;
953
+ }
954
+ return result;
955
+ }
565
956
  };
566
957
  }
567
958
  }