esupgrade 2025.17.0 → 2025.19.0

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 (36) hide show
  1. package/.pre-commit-config.yaml +1 -1
  2. package/README.md +32 -0
  3. package/bin/esupgrade.js +6 -1
  4. package/package.json +1 -1
  5. package/src/jQuery/nextToNextElementSibling.js +1 -2
  6. package/src/jQuery/prevToPreviousElementSibling.js +1 -2
  7. package/src/jQuery/readyToDOMContentLoaded.js +1 -2
  8. package/src/jQuery/siblingsToSiblingsArray.js +1 -2
  9. package/src/types.js +8 -5
  10. package/src/widelyAvailable/argumentsToRestParameters.js +1 -2
  11. package/src/widelyAvailable/arrayConcatToSpread.js +1 -3
  12. package/src/widelyAvailable/arrayFilterToFind.js +1 -3
  13. package/src/widelyAvailable/arrayFromForEachToForOf.js +1 -2
  14. package/src/widelyAvailable/arraySliceToSpread.js +1 -3
  15. package/src/widelyAvailable/consoleLogToInfo.js +2 -5
  16. package/src/widelyAvailable/constructorToClass.js +2 -4
  17. package/src/widelyAvailable/defaultParameterValues.js +1 -3
  18. package/src/widelyAvailable/forLoopToForOf.js +1 -3
  19. package/src/widelyAvailable/globalContextToGlobalThis.js +1 -2
  20. package/src/widelyAvailable/indexOfToIncludes.js +1 -3
  21. package/src/widelyAvailable/indexOfToStartsWith.js +1 -3
  22. package/src/widelyAvailable/iterableForEachToForOf.js +1 -2
  23. package/src/widelyAvailable/lastIndexOfToEndsWith.js +1 -3
  24. package/src/widelyAvailable/namedArrowFunctionToNamedFunction.js +1 -3
  25. package/src/widelyAvailable/nullishCoalescingOperator.js +1 -3
  26. package/src/widelyAvailable/objectKeysForEachToEntries.js +1 -3
  27. package/src/widelyAvailable/objectKeysMapToValues.js +1 -3
  28. package/src/widelyAvailable/objectPropertyExtractionToDestructuring.js +342 -0
  29. package/src/widelyAvailable/removeUseStrictFromModules.js +1 -3
  30. package/src/widelyAvailable/substrToSlice.js +1 -3
  31. package/src/widelyAvailable/substringToStartsWith.js +1 -3
  32. package/src/widelyAvailable.js +1 -0
  33. package/src/worker.js +1 -1
  34. package/tests/cli.test.js +26 -0
  35. package/tests/widelyAvailable/named-arrow-function-to-named-function.test.js +9 -0
  36. package/tests/widelyAvailable/object-property-extraction-to-destructuring.test.js +314 -0
@@ -32,7 +32,7 @@ repos:
32
32
  - id: write-good
33
33
  args: [--no-passive]
34
34
  - repo: https://github.com/pre-commit/mirrors-eslint
35
- rev: v10.3.0
35
+ rev: v10.4.0
36
36
  hooks:
37
37
  - id: eslint
38
38
  args: ["--fix"]
package/README.md CHANGED
@@ -550,6 +550,37 @@ The transformer handles cases where `Array.from(arguments)` has already been con
550
550
 
551
551
  Note: The `x = x || defaultValue` pattern is NOT transformed as it has different semantics (triggers on any falsy value, instead of `undefined`).
552
552
 
553
+ #### Manual property extraction → [Destructuring parameters][mdn-destructuring]
554
+
555
+ ```diff
556
+ -function fn(obj) {
557
+ - const x = obj.x;
558
+ - const y = obj.y;
559
+ - // use x and y
560
+ -}
561
+ +function fn({x, y}) {
562
+ + // use x and y
563
+ +}
564
+ ```
565
+
566
+ Transforms functions where the body begins by extracting properties from a parameter into object destructuring in the parameter list. Aliased extractions use longhand syntax:
567
+
568
+ ```diff
569
+ -function fn(obj) {
570
+ - const myX = obj.x;
571
+ -}
572
+ +function fn({x: myX}) {
573
+ +}
574
+ ```
575
+
576
+ Transforms when:
577
+
578
+ - The parameter is a simple identifier (not already destructured or a rest parameter)
579
+ - Leading statements are variable declarations extracting non-computed properties from that parameter
580
+ - The original parameter identifier is not referenced after the extraction zone
581
+
582
+ TypeScript type annotations on the original parameter are preserved on the resulting destructuring pattern.
583
+
553
584
  #### Promise chains → [async/await][mdn-async-await]
554
585
 
555
586
  ```diff
@@ -809,6 +840,7 @@ Furthermore, esupgrade supports JavaScript, TypeScript, and more, while lebab is
809
840
  [mdn-console]: https://developer.mozilla.org/en-US/docs/Web/API/console
810
841
  [mdn-const]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/const
811
842
  [mdn-default-parameters]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/Default_parameters
843
+ [mdn-destructuring]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment
812
844
  [mdn-endswith]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/endsWith
813
845
  [mdn-exponentiation]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Exponentiation
814
846
  [mdn-find]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/find
package/bin/esupgrade.js CHANGED
@@ -58,6 +58,7 @@ class FileProcessor {
58
58
  * @param {string} options.baseline - Baseline level for transformations.
59
59
  * @param {boolean} options.check - Whether to only check for changes.
60
60
  * @param {boolean} options.write - Whether to write changes to file.
61
+ * @param {boolean} options.verbose - The verbosity level for logging.
61
62
  * @param {boolean} options.jQuery - Whether to include jQuery transformers.
62
63
  * @returns {Promise<{modified: boolean, error: boolean}>} Result of processing.
63
64
  */
@@ -82,7 +83,10 @@ class FileProcessor {
82
83
  )
83
84
 
84
85
  if (!workerResult.success) {
85
- console.error(`\x1b[31m✗\x1b[0m Error: ${filePath}: ${workerResult.error}`)
86
+ if (options.verbose) console.error(workerResult.error)
87
+ console.error(
88
+ `\x1b[31m✗\x1b[0m Error: ${filePath}: ${workerResult.error.message}`,
89
+ )
86
90
  return { modified: false, error: true }
87
91
  }
88
92
 
@@ -266,6 +270,7 @@ program
266
270
  .choices(["widely-available", "newly-available"])
267
271
  .default("widely-available"),
268
272
  )
273
+ .option("--verbose, -v", "Show more detailed output.", false)
269
274
  .option(
270
275
  "--check",
271
276
  "Report which files need upgrading and exit with code 1 if any do",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "esupgrade",
3
- "version": "2025.17.0",
3
+ "version": "2025.19.0",
4
4
  "description": "Auto-upgrade your JavaScript syntax",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -11,8 +11,7 @@ export function nextToNextElementSibling(root) {
11
11
  let modified = false
12
12
  root
13
13
  .find(j.CallExpression)
14
- .filter((path) => {
15
- const node = path.node
14
+ .filter(({ node }) => {
16
15
  if (!j.MemberExpression.check(node.callee)) return false
17
16
  if (!j.Identifier.check(node.callee.property)) return false
18
17
  if (node.callee.property.name !== "next") return false
@@ -11,8 +11,7 @@ export function prevToPreviousElementSibling(root) {
11
11
  let modified = false
12
12
  root
13
13
  .find(j.CallExpression)
14
- .filter((path) => {
15
- const node = path.node
14
+ .filter(({ node }) => {
16
15
  if (!j.MemberExpression.check(node.callee)) return false
17
16
  if (!j.Identifier.check(node.callee.property)) return false
18
17
  if (node.callee.property.name !== "prev") return false
@@ -12,8 +12,7 @@ export function readyToDOMContentLoaded(root) {
12
12
  .find(j.CallExpression, {
13
13
  callee: { type: "MemberExpression" },
14
14
  })
15
- .filter((path) => {
16
- const node = path.node
15
+ .filter(({ node }) => {
17
16
  const callee = node.callee
18
17
  if (!j.Identifier.check(callee.property)) return false
19
18
  if (callee.property.name !== "ready") return false
@@ -11,8 +11,7 @@ export function siblingsToSiblingsArray(root) {
11
11
  let modified = false
12
12
  root
13
13
  .find(j.CallExpression)
14
- .filter((path) => {
15
- const node = path.node
14
+ .filter(({ node }) => {
16
15
  if (!j.MemberExpression.check(node.callee)) return false
17
16
  if (!j.Identifier.check(node.callee.property)) return false
18
17
  if (node.callee.property.name !== "siblings") return false
package/src/types.js CHANGED
@@ -293,6 +293,10 @@ export class NodeTest {
293
293
  * @returns {boolean} True if predicate returned true for any node
294
294
  */
295
295
  #traverseForPredicate(astNode, predicate) {
296
+ if (!astNode) {
297
+ return false
298
+ }
299
+
296
300
  if (predicate(astNode)) {
297
301
  return true
298
302
  }
@@ -504,9 +508,9 @@ export class NodeTest {
504
508
  */
505
509
  hasSideEffects() {
506
510
  const paramNames = new Set(
507
- this.node.params.flatMap((param) =>
508
- Array.from(new NodeTest(param).extractIdentifiersFromPattern()),
509
- ),
511
+ this.node.params.flatMap((param) => [
512
+ ...new NodeTest(param).extractIdentifiersFromPattern(),
513
+ ]),
510
514
  )
511
515
  return this.#hasNodeSideEffects(this.node.body, paramNames)
512
516
  }
@@ -1066,8 +1070,7 @@ export function processMultipleDeclarators(root, path) {
1066
1070
  * @param {string} name - Name to check for shadowing
1067
1071
  * @returns {boolean} True if the identifier is shadowed
1068
1072
  */
1069
- export function isShadowed(path, name) {
1070
- let scope = path.scope
1073
+ export function isShadowed({ scope }, name) {
1071
1074
  while (scope) {
1072
1075
  if (scope.getBindings()[name]) {
1073
1076
  return true
@@ -24,8 +24,7 @@ export function argumentsToRestParameters(root) {
24
24
  ...root.find(j.FunctionExpression).paths(),
25
25
  ]
26
26
 
27
- functionNodes.forEach((path) => {
28
- const func = path.node
27
+ functionNodes.forEach(({ node: func }) => {
29
28
  if (
30
29
  (func.params.length > 0 &&
31
30
  j.RestElement.check(func.params[func.params.length - 1])) ||
@@ -13,9 +13,7 @@ export function arrayConcatToSpread(root) {
13
13
 
14
14
  root
15
15
  .find(j.CallExpression)
16
- .filter((path) => {
17
- const node = path.node
18
-
16
+ .filter(({ node }) => {
19
17
  // Check if this is a .concat() call
20
18
  if (
21
19
  !j.MemberExpression.check(node.callee) ||
@@ -14,9 +14,7 @@ export function arrayFilterToFind(root) {
14
14
 
15
15
  root
16
16
  .find(j.MemberExpression)
17
- .filter((path) => {
18
- const node = path.node
19
-
17
+ .filter(({ node }) => {
20
18
  // Must be computed access: expr[0]
21
19
  if (!node.computed) {
22
20
  return false
@@ -13,8 +13,7 @@ export function arrayFromForEachToForOf(root) {
13
13
 
14
14
  root
15
15
  .find(j.CallExpression)
16
- .filter((path) => {
17
- const node = path.node
16
+ .filter(({ node }) => {
18
17
  // Check if this is a forEach call
19
18
  if (
20
19
  !j.MemberExpression.check(node.callee) ||
@@ -13,9 +13,7 @@ export function arraySliceToSpread(root) {
13
13
 
14
14
  root
15
15
  .find(j.CallExpression)
16
- .filter((path) => {
17
- const node = path.node
18
-
16
+ .filter(({ node }) => {
19
17
  // Check if this is a .slice() call
20
18
  if (
21
19
  !j.MemberExpression.check(node.callee) ||
@@ -12,8 +12,7 @@ export function consoleLogToInfo(root) {
12
12
 
13
13
  root
14
14
  .find(j.CallExpression)
15
- .filter((path) => {
16
- const node = path.node
15
+ .filter(({ node }) => {
17
16
  // Check if this is a console.log() call
18
17
  if (
19
18
  !j.MemberExpression.check(node.callee) ||
@@ -27,9 +26,7 @@ export function consoleLogToInfo(root) {
27
26
 
28
27
  return true
29
28
  })
30
- .forEach((path) => {
31
- const node = path.node
32
-
29
+ .forEach(({ node }) => {
33
30
  // Replace the property name from 'log' to 'info'
34
31
  node.callee.property.name = "info"
35
32
 
@@ -61,8 +61,7 @@ export function constructorToClass(root) {
61
61
  // Pattern 1: ConstructorName.prototype.methodName = ...
62
62
  root
63
63
  .find(j.ExpressionStatement)
64
- .filter((path) => {
65
- const node = path.node
64
+ .filter(({ node }) => {
66
65
  if (!j.AssignmentExpression.check(node.expression)) {
67
66
  return false
68
67
  }
@@ -105,8 +104,7 @@ export function constructorToClass(root) {
105
104
  // Pattern 2: ConstructorName.prototype = { methodName: function() {...}, ... }
106
105
  root
107
106
  .find(j.ExpressionStatement)
108
- .filter((path) => {
109
- const node = path.node
107
+ .filter(({ node }) => {
110
108
  if (!j.AssignmentExpression.check(node.expression)) {
111
109
  return false
112
110
  }
@@ -27,9 +27,7 @@ export function defaultParameterValues(root) {
27
27
  ...root.find(j.ArrowFunctionExpression).paths(),
28
28
  ]
29
29
 
30
- functionNodes.forEach((path) => {
31
- const func = path.node
32
-
30
+ functionNodes.forEach(({ node: func }) => {
33
31
  // Skip if function body is not a block statement
34
32
  if (!j.BlockStatement.check(func.body)) {
35
33
  return
@@ -14,9 +14,7 @@ export function forLoopToForOf(root) {
14
14
 
15
15
  root
16
16
  .find(j.ForStatement)
17
- .filter((path) => {
18
- const node = path.node
19
-
17
+ .filter(({ node }) => {
20
18
  // Check init: must be let/const i = 0
21
19
  if (!j.VariableDeclaration.check(node.init)) {
22
20
  return false
@@ -14,8 +14,7 @@ export function globalContextToGlobalThis(root) {
14
14
 
15
15
  root
16
16
  .find(j.CallExpression)
17
- .filter((path) => {
18
- const node = path.node
17
+ .filter(({ node }) => {
19
18
  // Check if this is a call to Function constructor
20
19
  if (
21
20
  !j.Identifier.check(node.callee) ||
@@ -16,9 +16,7 @@ export function indexOfToIncludes(root) {
16
16
 
17
17
  root
18
18
  .find(j.BinaryExpression)
19
- .filter((path) => {
20
- const node = path.node
21
-
19
+ .filter(({ node }) => {
22
20
  // Check for comparison operators: !==, ===, >, >=, <, <=
23
21
  if (!["!==", "===", ">", ">=", "<", "<="].includes(node.operator)) {
24
22
  return false
@@ -14,9 +14,7 @@ export function indexOfToStartsWith(root) {
14
14
 
15
15
  root
16
16
  .find(j.BinaryExpression)
17
- .filter((path) => {
18
- const node = path.node
19
-
17
+ .filter(({ node }) => {
20
18
  // Check for === or !== operators
21
19
  if (!["===", "!=="].includes(node.operator)) {
22
20
  return false
@@ -31,8 +31,7 @@ export function iterableForEachToForOf(root) {
31
31
 
32
32
  root
33
33
  .find(j.CallExpression)
34
- .filter((path) => {
35
- const node = path.node
34
+ .filter(({ node }) => {
36
35
  // Check if this is a forEach call
37
36
  if (
38
37
  !j.MemberExpression.check(node.callee) ||
@@ -15,9 +15,7 @@ export function lastIndexOfToEndsWith(root) {
15
15
 
16
16
  root
17
17
  .find(j.BinaryExpression)
18
- .filter((path) => {
19
- const node = path.node
20
-
18
+ .filter(({ node }) => {
21
19
  // Check for === or !== operators
22
20
  if (!["===", "!=="].includes(node.operator)) {
23
21
  return false
@@ -27,9 +27,7 @@ export function namedArrowFunctionToNamedFunction(root) {
27
27
 
28
28
  root
29
29
  .find(j.VariableDeclaration)
30
- .filter((path) => {
31
- const node = path.node
32
-
30
+ .filter(({ node }) => {
33
31
  // Must have exactly one declarator
34
32
  if (node.declarations.length !== 1) {
35
33
  return false
@@ -13,9 +13,7 @@ export function nullishCoalescingOperator(root) {
13
13
 
14
14
  root
15
15
  .find(j.ConditionalExpression)
16
- .filter((path) => {
17
- const node = path.node
18
-
16
+ .filter(({ node }) => {
19
17
  // Test must be a logical AND expression
20
18
  if (!j.LogicalExpression.check(node.test) || node.test.operator !== "&&") {
21
19
  return false
@@ -15,9 +15,7 @@ export function objectKeysForEachToEntries(root) {
15
15
 
16
16
  root
17
17
  .find(j.CallExpression)
18
- .filter((path) => {
19
- const node = path.node
20
-
18
+ .filter(({ node }) => {
21
19
  // Check if this is a forEach call
22
20
  if (
23
21
  !j.MemberExpression.check(node.callee) ||
@@ -48,9 +48,7 @@ export function objectKeysMapToValues(root) {
48
48
 
49
49
  root
50
50
  .find(j.CallExpression)
51
- .filter((path) => {
52
- const node = path.node
53
-
51
+ .filter(({ node }) => {
54
52
  if (
55
53
  !j.MemberExpression.check(node.callee) ||
56
54
  !j.Identifier.check(node.callee.property) ||
@@ -0,0 +1,342 @@
1
+ import { default as j } from "jscodeshift"
2
+
3
+ const SKIP_KEYS = new Set(["loc", "start", "end", "tokens", "comments"])
4
+
5
+ /**
6
+ * Transform manual property extraction to destructuring in function parameters.
7
+ * Converts patterns where a function body begins with property extractions from a
8
+ * parameter into an object destructuring pattern in the parameter list.
9
+ *
10
+ * Only transforms when:
11
+ * - The parameter is a simple identifier (not already destructured or rest)
12
+ * - Leading statements are `const`/`let`/`var` declarations extracting non-computed
13
+ * properties from that parameter
14
+ * - The original parameter identifier is not referenced after the extraction zone
15
+ *
16
+ * @param {import("jscodeshift").Collection} root - The root AST collection
17
+ * @returns {boolean} True if code was modified
18
+ * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment
19
+ */
20
+ export function objectPropertyExtractionToDestructuring(root) {
21
+ let modified = false
22
+
23
+ const functionNodes = [
24
+ ...root.find(j.FunctionDeclaration).paths(),
25
+ ...root.find(j.FunctionExpression).paths(),
26
+ ...root.find(j.ArrowFunctionExpression).paths(),
27
+ ]
28
+
29
+ functionNodes.forEach(({ node: func }) => {
30
+ if (!j.BlockStatement.check(func.body)) {
31
+ return
32
+ }
33
+
34
+ const body = func.body
35
+
36
+ func.params.forEach((param, paramIndex) => {
37
+ if (!j.Identifier.check(param)) {
38
+ return
39
+ }
40
+
41
+ const paramName = param.name
42
+ const result = findExtractions(body, paramName)
43
+
44
+ if (result.extractions.length === 0) {
45
+ return
46
+ }
47
+
48
+ if (isParamUsedAfterExtractions(body, paramName, result)) {
49
+ return
50
+ }
51
+
52
+ if (isParamReferencedInOtherParams(func.params, paramIndex, paramName)) {
53
+ return
54
+ }
55
+
56
+ if (wouldPromoteDirective(body, result)) {
57
+ return
58
+ }
59
+
60
+ func.params[paramIndex] = buildObjectPattern(result.extractions, param)
61
+ removeExtractionDeclarators(body, result.extractions)
62
+ modified = true
63
+ })
64
+ })
65
+
66
+ return modified
67
+ }
68
+
69
+ /**
70
+ * Find leading property extractions from a named parameter in a function body.
71
+ *
72
+ * @param {import("ast-types").namedTypes.BlockStatement} body - The function body
73
+ * @param {string} paramName - The parameter identifier name to extract from
74
+ * @returns {{ extractions: Array<{localName: string, propertyName: string, statementIndex: number, declaratorIndex: number}>, boundary: number }}
75
+ */
76
+ function findExtractions(body, paramName) {
77
+ const extractions = []
78
+ let boundary = 0
79
+
80
+ for (let i = 0; i < body.body.length; i++) {
81
+ const statement = body.body[i]
82
+
83
+ if (!j.VariableDeclaration.check(statement)) {
84
+ break
85
+ }
86
+
87
+ let hasExtractionInStatement = false
88
+
89
+ statement.declarations.forEach((declarator, declaratorIndex) => {
90
+ if (isPropertyExtractionFrom(declarator, paramName)) {
91
+ extractions.push({
92
+ localName: declarator.id.name,
93
+ propertyName: declarator.init.property.name,
94
+ statementIndex: i,
95
+ declaratorIndex,
96
+ })
97
+ hasExtractionInStatement = true
98
+ }
99
+ })
100
+
101
+ if (!hasExtractionInStatement) {
102
+ break
103
+ }
104
+
105
+ boundary = i + 1
106
+ }
107
+
108
+ return { extractions, boundary }
109
+ }
110
+
111
+ /**
112
+ * Check if a variable declarator is a non-computed property access from a named identifier.
113
+ *
114
+ * @param {import("ast-types").namedTypes.VariableDeclarator} declarator - The declarator to check
115
+ * @param {string} paramName - The identifier name to check against
116
+ * @returns {boolean} True if the declarator extracts a property from paramName
117
+ */
118
+ function isPropertyExtractionFrom(declarator, paramName) {
119
+ return (
120
+ j.Identifier.check(declarator.id) &&
121
+ j.MemberExpression.check(declarator.init) &&
122
+ !declarator.init.computed &&
123
+ j.Identifier.check(declarator.init.object) &&
124
+ declarator.init.object.name === paramName &&
125
+ j.Identifier.check(declarator.init.property)
126
+ )
127
+ }
128
+
129
+ /**
130
+ * Check if the original parameter identifier is still referenced after the extraction zone.
131
+ * Uses deep traversal that crosses nested function boundaries, since the parameter
132
+ * name is in the outer scope and closures may reference it.
133
+ *
134
+ * @param {import("ast-types").namedTypes.BlockStatement} body - The function body
135
+ * @param {string} paramName - The parameter identifier name
136
+ * @param {{ extractions: Array, boundary: number }} result - Result from findExtractions
137
+ * @returns {boolean} True if the parameter is used after the extractions
138
+ */
139
+ function isParamUsedAfterExtractions(body, paramName, result) {
140
+ const remainingStatements = body.body.slice(result.boundary)
141
+
142
+ if (remainingStatements.some((stmt) => deepContainsIdentifier(stmt, paramName))) {
143
+ return true
144
+ }
145
+
146
+ return isMixedDeclaratorUsingParam(body, paramName, result.extractions)
147
+ }
148
+
149
+ /**
150
+ * Check if any non-extraction declarator in the extraction zone references the parameter.
151
+ *
152
+ * @param {import("ast-types").namedTypes.BlockStatement} body - The function body
153
+ * @param {string} paramName - The parameter identifier name
154
+ * @param {Array<{statementIndex: number, declaratorIndex: number}>} extractions - The found extractions
155
+ * @returns {boolean} True if the parameter is used in a non-extraction declarator
156
+ */
157
+ function isMixedDeclaratorUsingParam(body, paramName, extractions) {
158
+ const extractionSet = new Set(
159
+ extractions.map(
160
+ ({ statementIndex, declaratorIndex }) => `${statementIndex}:${declaratorIndex}`,
161
+ ),
162
+ )
163
+
164
+ return body.body.some((statement, statementIndex) => {
165
+ if (!j.VariableDeclaration.check(statement)) {
166
+ return false
167
+ }
168
+
169
+ return statement.declarations.some((declarator, declaratorIndex) => {
170
+ if (extractionSet.has(`${statementIndex}:${declaratorIndex}`)) {
171
+ return false
172
+ }
173
+
174
+ if (!declarator.init) {
175
+ return false
176
+ }
177
+
178
+ return deepContainsIdentifier(declarator.init, paramName)
179
+ })
180
+ })
181
+ }
182
+
183
+ /**
184
+ * Check if removing extraction statements would promote a "use strict" string literal
185
+ * to a function directive. Non-simple parameter lists (destructuring) cannot have a
186
+ * "use strict" directive.
187
+ *
188
+ * @param {import("ast-types").namedTypes.BlockStatement} body - The function body
189
+ * @param {{ extractions: Array<{statementIndex: number, declaratorIndex: number}>, boundary: number }} result - Result from findExtractions
190
+ * @returns {boolean} True if the transformation would produce an illegal directive
191
+ */
192
+ function wouldPromoteDirective(body, result) {
193
+ const extractionSet = new Set(
194
+ result.extractions.map(
195
+ ({ statementIndex, declaratorIndex }) => `${statementIndex}:${declaratorIndex}`,
196
+ ),
197
+ )
198
+
199
+ for (let i = 0; i < result.boundary; i++) {
200
+ const statement = body.body[i]
201
+ const hasRemainingDeclarators = statement.declarations.some(
202
+ (_, di) => !extractionSet.has(`${i}:${di}`),
203
+ )
204
+
205
+ if (hasRemainingDeclarators) {
206
+ return false
207
+ }
208
+ }
209
+
210
+ const nextStatement = body.body[result.boundary]
211
+
212
+ return (
213
+ nextStatement !== undefined &&
214
+ j.ExpressionStatement.check(nextStatement) &&
215
+ isStringLiteralNode(nextStatement.expression) &&
216
+ nextStatement.expression.value === "use strict"
217
+ )
218
+ }
219
+
220
+ /**
221
+ * Check if the parameter identifier is referenced in any other parameter in the list.
222
+ * Covers default values such as `function fn(obj, y = obj.x)`.
223
+ *
224
+ * @param {Array<import("ast-types").ASTNode>} params - The full parameter list
225
+ * @param {number} paramIndex - Index of the parameter being transformed
226
+ * @param {string} paramName - The parameter identifier name
227
+ * @returns {boolean} True if the identifier appears in another parameter
228
+ */
229
+ function isParamReferencedInOtherParams(params, paramIndex, paramName) {
230
+ return params.some(
231
+ (param, i) => i !== paramIndex && deepContainsIdentifier(param, paramName),
232
+ )
233
+ }
234
+
235
+ /**
236
+ * Deeply check if an identifier name appears anywhere in an AST subtree,
237
+ * including inside nested functions and arrow functions.
238
+ *
239
+ * @param {import("ast-types").ASTNode | null | undefined} node - The node to search
240
+ * @param {string} name - The identifier name to search for
241
+ * @returns {boolean} True if the identifier is found anywhere in the subtree
242
+ */
243
+ function deepContainsIdentifier(node, name) {
244
+ if (!node || typeof node !== "object") {
245
+ return false
246
+ }
247
+
248
+ if (node.type === "Identifier" && node.name === name) {
249
+ return true
250
+ }
251
+
252
+ for (const key in node) {
253
+ if (SKIP_KEYS.has(key)) {
254
+ continue
255
+ }
256
+
257
+ const value = node[key]
258
+
259
+ if (Array.isArray(value)) {
260
+ if (value.some((item) => deepContainsIdentifier(item, name))) {
261
+ return true
262
+ }
263
+ } else if (value && typeof value === "object") {
264
+ if (deepContainsIdentifier(value, name)) {
265
+ return true
266
+ }
267
+ }
268
+ }
269
+
270
+ return false
271
+ }
272
+
273
+ /**
274
+ * Check if a node is a string literal.
275
+ *
276
+ * @param {import("ast-types").ASTNode} node - The node to check
277
+ * @returns {boolean} True if the node is a string literal
278
+ */
279
+ function isStringLiteralNode(node) {
280
+ return j.StringLiteral.check(node)
281
+ }
282
+
283
+ /**
284
+ * Build an ObjectPattern AST node from a list of property extractions.
285
+ * Preserves TypeScript type annotations from the original parameter.
286
+ *
287
+ * @param {Array<{localName: string, propertyName: string}>} extractions - The extractions to build from
288
+ * @param {import("ast-types").namedTypes.Identifier} originalParam - The original parameter node
289
+ * @returns {import("ast-types").namedTypes.ObjectPattern} The destructuring pattern
290
+ */
291
+ function buildObjectPattern(extractions, originalParam) {
292
+ const properties = extractions.map(({ localName, propertyName }) => {
293
+ const key = j.identifier(propertyName)
294
+ const value = j.identifier(localName)
295
+ const isShorthand = localName === propertyName
296
+ const prop = j.objectProperty(key, value)
297
+ prop.shorthand = isShorthand
298
+ return prop
299
+ })
300
+
301
+ const pattern = j.objectPattern(properties)
302
+
303
+ if (originalParam.typeAnnotation) {
304
+ pattern.typeAnnotation = originalParam.typeAnnotation
305
+ }
306
+
307
+ return pattern
308
+ }
309
+
310
+ /**
311
+ * Remove extraction declarators from the function body, removing empty statements.
312
+ *
313
+ * @param {import("ast-types").namedTypes.BlockStatement} body - The function body to modify
314
+ * @param {Array<{statementIndex: number, declaratorIndex: number}>} extractions - The extractions to remove
315
+ */
316
+ function removeExtractionDeclarators(body, extractions) {
317
+ const statementMap = new Map()
318
+
319
+ extractions.forEach(({ statementIndex, declaratorIndex }) => {
320
+ if (!statementMap.has(statementIndex)) {
321
+ statementMap.set(statementIndex, [])
322
+ }
323
+ statementMap.get(statementIndex).push(declaratorIndex)
324
+ })
325
+
326
+ for (const [statementIndex, declaratorIndices] of [...statementMap.entries()].sort(
327
+ (a, b) => b[0] - a[0],
328
+ )) {
329
+ const statement = body.body[statementIndex]
330
+
331
+ declaratorIndices
332
+ .slice()
333
+ .sort((a, b) => b - a)
334
+ .forEach((di) => {
335
+ statement.declarations.splice(di, 1)
336
+ })
337
+
338
+ if (statement.declarations.length === 0) {
339
+ body.body.splice(statementIndex, 1)
340
+ }
341
+ }
342
+ }
@@ -26,9 +26,7 @@ export function removeUseStrictFromModules(root) {
26
26
  }
27
27
 
28
28
  // Find and remove 'use strict' directives
29
- root.find(j.Program).forEach((programPath) => {
30
- const program = programPath.node
31
-
29
+ root.find(j.Program).forEach(({ node: program }) => {
32
30
  // Check directives array (Babel/TSX parser stores directives here)
33
31
  if (program.directives && Array.isArray(program.directives)) {
34
32
  let i = 0
@@ -17,9 +17,7 @@ export function substrToSlice(root) {
17
17
 
18
18
  root
19
19
  .find(j.CallExpression)
20
- .filter((path) => {
21
- const node = path.node
22
-
20
+ .filter(({ node }) => {
23
21
  // Check if this is a .substr() call
24
22
  if (
25
23
  !j.MemberExpression.check(node.callee) ||
@@ -15,9 +15,7 @@ export function substringToStartsWith(root) {
15
15
 
16
16
  root
17
17
  .find(j.BinaryExpression)
18
- .filter((path) => {
19
- const node = path.node
20
-
18
+ .filter(({ node }) => {
21
19
  // Check for === or !== operators
22
20
  if (!["===", "!=="].includes(node.operator)) {
23
21
  return false
@@ -21,6 +21,7 @@ export { nullishCoalescingOperator } from "./widelyAvailable/nullishCoalescingOp
21
21
  export { objectAssignToSpread } from "./widelyAvailable/objectAssignToSpread.js"
22
22
  export { objectKeysForEachToEntries } from "./widelyAvailable/objectKeysForEachToEntries.js"
23
23
  export { objectKeysMapToValues } from "./widelyAvailable/objectKeysMapToValues.js"
24
+ export { objectPropertyExtractionToDestructuring } from "./widelyAvailable/objectPropertyExtractionToDestructuring.js"
24
25
  export { optionalChaining } from "./widelyAvailable/optionalChaining.js"
25
26
  export { promiseToAsyncAwait } from "./widelyAvailable/promiseToAsyncAwait.js"
26
27
  export { removeUseStrictFromModules } from "./widelyAvailable/removeUseStrictFromModules.js"
package/src/worker.js CHANGED
@@ -23,6 +23,6 @@ try {
23
23
  parentPort.postMessage({
24
24
  success: false,
25
25
  filePath,
26
- error: error.message,
26
+ error,
27
27
  })
28
28
  }
package/tests/cli.test.js CHANGED
@@ -542,4 +542,30 @@ describe("CLI", () => {
542
542
  )
543
543
  assert.equal(resultWithJQuery.status, 0, "exits successfully with --jQuery")
544
544
  })
545
+
546
+ test("show error message without stack trace at default verbosity", () => {
547
+ const testFile = path.join(tempDir, "test.js")
548
+ fs.writeFileSync(testFile, `var x = {{{;`)
549
+
550
+ const result = spawnSync(process.execPath, [CLI_PATH, testFile], {
551
+ encoding: "utf8",
552
+ })
553
+
554
+ assert.match(result.stderr, /Error:/, "displays error message")
555
+ assert.doesNotMatch(result.stderr, /\n\s*at /, "omits stack trace")
556
+ })
557
+
558
+ test("show full error with stack trace with --verbose", () => {
559
+ const testFile = path.join(tempDir, "test.js")
560
+ fs.writeFileSync(testFile, `var x = {{{;`)
561
+
562
+ const result = spawnSync(
563
+ process.execPath,
564
+ [CLI_PATH, testFile, "--verbose", "--verbose"],
565
+ { encoding: "utf8" },
566
+ )
567
+
568
+ assert.match(result.stderr, /Error:/, "displays error message")
569
+ assert.match(result.stderr, /\n\s*at /, "includes stack trace at verbosity level 2")
570
+ })
545
571
  })
@@ -116,6 +116,15 @@ suite("widely-available", () => {
116
116
  assert.match(result.code, /console\.info\('test'\)/)
117
117
  })
118
118
 
119
+ test("arrow function with array destructuring holes in body", () => {
120
+ const result = transform(
121
+ `const f = (arr) => { const [a, , b] = arr; return a + b; }`,
122
+ )
123
+
124
+ assert(result.modified, "transform arrow function with array holes in body")
125
+ assert.match(result.code, /function f\(arr\)/)
126
+ })
127
+
119
128
  test("skip arrow function using this", () => {
120
129
  const result = transform(`const method = () => { return this.value; }`)
121
130
 
@@ -0,0 +1,314 @@
1
+ import assert from "node:assert/strict"
2
+ import { describe, suite, test } from "node:test"
3
+ import { transform } from "../../src/index.js"
4
+
5
+ suite("widely-available", () => {
6
+ describe("objectPropertyExtractionToDestructuring", () => {
7
+ test("single property extraction in function declaration", () => {
8
+ const result = transform(`
9
+ function fn(obj) {
10
+ const x = obj.x;
11
+ return x;
12
+ }
13
+ `)
14
+
15
+ assert(result.modified, "transform single property extraction")
16
+ assert.match(result.code, /function fn\(\s*\{\s*x\s*\}/)
17
+ assert.doesNotMatch(result.code, /const x = obj\.x/)
18
+ })
19
+
20
+ test("multiple property extractions", () => {
21
+ const result = transform(`
22
+ function fn(obj) {
23
+ const x = obj.x;
24
+ const y = obj.y;
25
+ return x + y;
26
+ }
27
+ `)
28
+
29
+ assert(result.modified, "transform multiple property extractions")
30
+ assert.match(result.code, /function fn\(\s*\{\s*x,\s*y\s*\}/)
31
+ assert.doesNotMatch(result.code, /const x = obj\.x/)
32
+ assert.doesNotMatch(result.code, /const y = obj\.y/)
33
+ })
34
+
35
+ test("aliased extraction uses longhand destructuring", () => {
36
+ const result = transform(`
37
+ function fn(obj) {
38
+ const myX = obj.x;
39
+ return myX;
40
+ }
41
+ `)
42
+
43
+ assert(result.modified, "transform aliased property extraction")
44
+ assert.match(result.code, /function fn\(\s*\{\s*x:\s*myX\s*\}/)
45
+ assert.doesNotMatch(result.code, /const myX = obj\.x/)
46
+ })
47
+
48
+ test("skip when parameter is used after extractions", () => {
49
+ const result = transform(`
50
+ function fn(obj) {
51
+ const x = obj.x;
52
+ console.log(obj);
53
+ }
54
+ `)
55
+
56
+ assert.match(result.code, /const x = obj\.x/)
57
+ assert.match(result.code, /function fn\(obj\)/)
58
+ })
59
+
60
+ test("skip when parameter property is accessed after extractions", () => {
61
+ const result = transform(`
62
+ function fn(obj) {
63
+ const x = obj.x;
64
+ return x + obj.z;
65
+ }
66
+ `)
67
+
68
+ assert.match(result.code, /const x = obj\.x/)
69
+ assert.match(result.code, /function fn\(obj\)/)
70
+ })
71
+
72
+ test("skip computed property access", () => {
73
+ const result = transform(`
74
+ function fn(obj) {
75
+ const x = obj[key];
76
+ return x;
77
+ }
78
+ `)
79
+
80
+ assert(!result.modified, "skip computed property access")
81
+ })
82
+
83
+ test("skip already-destructured parameter", () => {
84
+ const result = transform(`
85
+ function fn({x}) {
86
+ return x;
87
+ }
88
+ `)
89
+
90
+ assert(!result.modified, "skip already-destructured parameter")
91
+ })
92
+
93
+ test("skip rest parameter", () => {
94
+ const result = transform(`
95
+ function fn(...args) {
96
+ return args;
97
+ }
98
+ `)
99
+
100
+ assert(!result.modified, "skip rest parameter")
101
+ })
102
+
103
+ test("skip non-leading extraction", () => {
104
+ const result = transform(`
105
+ function fn(obj) {
106
+ const z = 42;
107
+ const x = obj.x;
108
+ return x + z;
109
+ }
110
+ `)
111
+
112
+ assert.match(result.code, /const x = obj\.x/)
113
+ assert.match(result.code, /function fn\(obj\)/)
114
+ })
115
+
116
+ test("stop at non-extraction in leading statements", () => {
117
+ const result = transform(`
118
+ function fn(obj) {
119
+ const x = obj.x;
120
+ const z = 42;
121
+ const y = obj.y;
122
+ return x + y + z;
123
+ }
124
+ `)
125
+
126
+ assert.match(result.code, /const x = obj\.x/)
127
+ })
128
+
129
+ test("function expression", () => {
130
+ const result = transform(`
131
+ const fn = function(obj) {
132
+ const x = obj.x;
133
+ return x;
134
+ };
135
+ `)
136
+
137
+ assert(result.modified, "transform function expression")
138
+ assert.match(result.code, /\{\s*x\s*\}/)
139
+ assert.doesNotMatch(result.code, /const x = obj\.x/)
140
+ })
141
+
142
+ test("arrow function", () => {
143
+ const result = transform(`
144
+ const fn = (obj) => {
145
+ const x = obj.x;
146
+ return x;
147
+ };
148
+ `)
149
+
150
+ assert(result.modified, "transform arrow function")
151
+ assert.match(result.code, /\{\s*x\s*\}/)
152
+ assert.doesNotMatch(result.code, /const x = obj\.x/)
153
+ })
154
+
155
+ test("multiple parameters transformed independently", () => {
156
+ const result = transform(`
157
+ function fn(a, b) {
158
+ const x = a.x;
159
+ const y = b.y;
160
+ return x + y;
161
+ }
162
+ `)
163
+
164
+ assert(result.modified, "transform multiple parameters")
165
+ assert.doesNotMatch(result.code, /const x = a\.x/)
166
+ assert.doesNotMatch(result.code, /const y = b\.y/)
167
+ })
168
+
169
+ test("mixed declarator statement preserves non-extraction", () => {
170
+ const result = transform(`
171
+ function fn(obj) {
172
+ const x = obj.x, z = 42;
173
+ return x + z;
174
+ }
175
+ `)
176
+
177
+ assert(result.modified, "transform mixed declarator")
178
+ assert.match(result.code, /const z = 42/)
179
+ assert.doesNotMatch(result.code, /obj/)
180
+ })
181
+
182
+ test("TypeScript type annotation is preserved", () => {
183
+ const result = transform(`
184
+ function fn(obj: MyType) {
185
+ const x = obj.x;
186
+ return x;
187
+ }
188
+ `)
189
+
190
+ assert(result.modified, "transform TypeScript typed parameter")
191
+ assert.match(result.code, /\{\s*x\s*\}:\s*MyType/)
192
+ assert.doesNotMatch(result.code, /const x = obj\.x/)
193
+ })
194
+
195
+ test("TypeScript inline type annotation is preserved", () => {
196
+ const result = transform(`
197
+ function fn(obj: { x: number }) {
198
+ const x = obj.x;
199
+ return x;
200
+ }
201
+ `)
202
+
203
+ assert(result.modified, "transform TypeScript inline typed parameter")
204
+ assert.match(result.code, /x: number/)
205
+ assert.doesNotMatch(result.code, /const x = obj\.x/)
206
+ })
207
+
208
+ test("skip mixed declarator where non-extraction uses the param", () => {
209
+ const result = transform(`
210
+ function fn(obj) {
211
+ const x = obj.x, ref = obj;
212
+ return x;
213
+ }
214
+ `)
215
+
216
+ assert.match(result.code, /function fn\(obj\)/)
217
+ assert.match(result.code, /const x = obj\.x/)
218
+ })
219
+
220
+ test("uninitialised variable declaration does not crash", () => {
221
+ const result = transform(`
222
+ function fn(obj) {
223
+ const x = obj.x;
224
+ let y;
225
+ return x;
226
+ }
227
+ `)
228
+
229
+ assert(result.modified, "transform with uninitialised variable in body")
230
+ assert.match(result.code, /function fn\(\s*\{\s*x\s*\}/)
231
+ assert.doesNotMatch(result.code, /const x = obj\.x/)
232
+ })
233
+
234
+ test("skip when nested function closes over the parameter", () => {
235
+ const result = transform(`
236
+ function fn(obj) {
237
+ const x = obj.x;
238
+ return () => obj;
239
+ }
240
+ `)
241
+
242
+ assert(
243
+ !result.modified,
244
+ "should not transform when nested function closes over parameter",
245
+ )
246
+ assert.match(result.code, /function fn\(obj\)/)
247
+ assert.match(result.code, /const x = obj\.x/)
248
+ })
249
+
250
+ test("skip when extraction removal would promote use strict to a directive", () => {
251
+ const result = transform(`
252
+ function fn(obj) {
253
+ const x = obj.x;
254
+ "use strict";
255
+ return x;
256
+ }
257
+ `)
258
+
259
+ assert(
260
+ !result.modified,
261
+ "should not transform when removal would promote use strict directive",
262
+ )
263
+ assert.match(result.code, /function fn\(obj\)/)
264
+ assert.match(result.code, /const x = obj\.x/)
265
+ })
266
+
267
+ test("transform when non-use-strict string literal follows extractions", () => {
268
+ const result = transform(`
269
+ function fn(obj) {
270
+ const x = obj.x;
271
+ "@jsx";
272
+ return x;
273
+ }
274
+ `)
275
+
276
+ assert(result.modified, "should transform when string literal is not use strict")
277
+ assert.match(result.code, /function fn\(\s*\{\s*x\s*\}/)
278
+ assert.doesNotMatch(result.code, /const x = obj\.x/)
279
+ })
280
+
281
+ test("skip when parameter is referenced in another parameter's default value", () => {
282
+ const result = transform(`
283
+ function fn(obj, y = obj.x) {
284
+ const x = obj.x;
285
+ return x + y;
286
+ }
287
+ `)
288
+
289
+ assert(
290
+ !result.modified,
291
+ "should not transform when param used in other param default",
292
+ )
293
+ assert.match(result.code, /function fn\(obj,/)
294
+ assert.match(result.code, /const x = obj\.x/)
295
+ })
296
+
297
+ test("skip when body contains sparse array pattern referencing parameter", () => {
298
+ const result = transform(`
299
+ function fn(obj) {
300
+ const x = obj.x;
301
+ const [, b] = obj.arr;
302
+ return x + b;
303
+ }
304
+ `)
305
+
306
+ assert(
307
+ !result.modified,
308
+ "should not transform when obj is used after extractions",
309
+ )
310
+ assert.match(result.code, /function fn\(obj\)/)
311
+ assert.match(result.code, /const x = obj\.x/)
312
+ })
313
+ })
314
+ })