esupgrade 2025.3.3 → 2025.3.4

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.
package/bin/esupgrade.js CHANGED
@@ -74,7 +74,8 @@ class FileProcessor {
74
74
  const result = workerResult.result
75
75
 
76
76
  if (result.modified) {
77
- if (options.check) {
77
+ // Report changes if check mode or if not writing (dry-run)
78
+ if (options.check || !options.write) {
78
79
  this.#reportChanges(filePath, result.changes)
79
80
  }
80
81
 
@@ -89,6 +90,7 @@ class FileProcessor {
89
90
 
90
91
  return { modified: true, changes: result.changes, error: false }
91
92
  } else {
93
+ // Show unmodified files unless in check-only mode
92
94
  if (!options.check) {
93
95
  console.log(` ${filePath}`)
94
96
  }
@@ -237,6 +239,14 @@ class CLIRunner {
237
239
  this.#reportSummary(results, options)
238
240
  }
239
241
 
242
+ #formatDetailedSummary(modifiedCount, allChanges, actionVerb) {
243
+ const transformTypes = new Set(allChanges.map((c) => c.type))
244
+ const typeCount = transformTypes.size
245
+ const totalChanges = allChanges.length
246
+
247
+ return `${modifiedCount} file${modifiedCount !== 1 ? "s" : ""} ${actionVerb} (${totalChanges} change${totalChanges !== 1 ? "s" : ""}, ${typeCount} type${typeCount !== 1 ? "s" : ""})`
248
+ }
249
+
240
250
  #reportSummary(results, options) {
241
251
  let modifiedCount = 0
242
252
  const allChanges = results.flatMap((result) =>
@@ -249,12 +259,12 @@ class CLIRunner {
249
259
 
250
260
  if (options.check) {
251
261
  if (modifiedCount > 0) {
252
- const transformTypes = new Set(allChanges.map((c) => c.type))
253
- const typeCount = transformTypes.size
254
- const totalChanges = allChanges.length
255
-
256
262
  console.log(
257
- `${modifiedCount} file${modifiedCount !== 1 ? "s" : ""} need${modifiedCount === 1 ? "s" : ""} upgrading (${totalChanges} change${totalChanges !== 1 ? "s" : ""}, ${typeCount} type${typeCount !== 1 ? "s" : ""})`,
263
+ this.#formatDetailedSummary(
264
+ modifiedCount,
265
+ allChanges,
266
+ `need${modifiedCount === 1 ? "s" : ""} upgrading`,
267
+ ),
258
268
  )
259
269
  if (options.write) {
260
270
  console.log("Changes have been written")
@@ -262,12 +272,22 @@ class CLIRunner {
262
272
  } else {
263
273
  console.log("All files are up to date")
264
274
  }
265
- } else {
275
+ } else if (options.write) {
276
+ // --write without --check
266
277
  if (modifiedCount > 0) {
267
278
  console.log(`✓ ${modifiedCount} file${modifiedCount !== 1 ? "s" : ""} upgraded`)
268
279
  } else {
269
280
  console.log("All files are up to date")
270
281
  }
282
+ } else {
283
+ // Dry-run mode (no --check, no --write)
284
+ if (modifiedCount > 0) {
285
+ console.log(
286
+ this.#formatDetailedSummary(modifiedCount, allChanges, "would be upgraded"),
287
+ )
288
+ } else {
289
+ console.log("All files are up to date")
290
+ }
271
291
  }
272
292
 
273
293
  // Errors take precedence over --check flag.
@@ -296,14 +316,11 @@ program
296
316
  .default("widely-available"),
297
317
  )
298
318
  .option("--check", "Report which files need upgrading and exit with code 1 if any do")
299
- .option(
300
- "--write",
301
- "Write changes to files (default: true unless only --check is specified)",
302
- )
319
+ .option("--write", "Write changes to files")
303
320
  .action(async (files, options) => {
304
321
  // Handle check/write options - they are not mutually exclusive
305
- // Default: write is true unless ONLY --check is specified (no --write)
306
- const shouldWrite = options.write !== undefined ? options.write : !options.check
322
+ // Default: write is false (read-only mode unless --write is specified)
323
+ const shouldWrite = options.write || false
307
324
  const shouldCheck = options.check || false
308
325
 
309
326
  const processingOptions = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "esupgrade",
3
- "version": "2025.3.3",
3
+ "version": "2025.3.4",
4
4
  "description": "Auto-upgrade your JavaScript syntax",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -1,20 +1,267 @@
1
1
  /**
2
- * Transform var to const
2
+ * Check if a pattern (identifier, destructuring, etc.) contains a specific variable name
3
+ * @param {import('jscodeshift').JSCodeshift} j - The jscodeshift API
4
+ * @param {import('jscodeshift').ASTNode | null | undefined} node - The AST node to check
5
+ * @param {string} varName - The variable name to search for
6
+ * @returns {boolean} True if the pattern contains the identifier
7
+ */
8
+ function patternContainsIdentifier(j, node, varName) {
9
+ if (!node) {
10
+ return false
11
+ }
12
+ if (j.Identifier.check(node)) {
13
+ return node.name === varName
14
+ }
15
+ if (j.ObjectPattern.check(node)) {
16
+ return node.properties.some(
17
+ (prop) =>
18
+ ((j.Property.check(prop) || j.ObjectProperty.check(prop)) &&
19
+ patternContainsIdentifier(j, prop.value, varName)) ||
20
+ (j.RestElement.check(prop) &&
21
+ patternContainsIdentifier(j, prop.argument, varName)),
22
+ )
23
+ }
24
+ if (j.ArrayPattern.check(node)) {
25
+ return node.elements.some((element) =>
26
+ patternContainsIdentifier(j, element, varName),
27
+ )
28
+ }
29
+ if (j.AssignmentPattern.check(node)) {
30
+ return patternContainsIdentifier(j, node.left, varName)
31
+ }
32
+ // RestElement is the only remaining valid pattern type
33
+ return (
34
+ j.RestElement.check(node) && patternContainsIdentifier(j, node.argument, varName)
35
+ )
36
+ }
37
+
38
+ /**
39
+ * Extract all identifier names from a pattern (handles destructuring)
40
+ * @param {import('jscodeshift').JSCodeshift} j - The jscodeshift API
41
+ * @param {import('jscodeshift').ASTNode | null | undefined} pattern - The pattern node to extract identifiers from
42
+ * @yields {string} Identifier names found in the pattern
43
+ * @returns {Generator<string, void, unknown>}
44
+ */
45
+ function* extractIdentifiersFromPattern(j, pattern) {
46
+ if (!pattern) return
47
+
48
+ if (j.Identifier.check(pattern)) {
49
+ yield pattern.name
50
+ } else if (j.ObjectPattern.check(pattern)) {
51
+ for (const prop of pattern.properties) {
52
+ if (j.Property.check(prop) || j.ObjectProperty.check(prop)) {
53
+ yield* extractIdentifiersFromPattern(j, prop.value)
54
+ } else if (j.RestElement.check(prop)) {
55
+ yield* extractIdentifiersFromPattern(j, prop.argument)
56
+ }
57
+ }
58
+ } else if (j.ArrayPattern.check(pattern)) {
59
+ for (const element of pattern.elements) {
60
+ yield* extractIdentifiersFromPattern(j, element)
61
+ }
62
+ } else if (j.AssignmentPattern.check(pattern)) {
63
+ yield* extractIdentifiersFromPattern(j, pattern.left)
64
+ } else if (j.RestElement.check(pattern)) {
65
+ yield* extractIdentifiersFromPattern(j, pattern.argument)
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Check if an assignment/update expression is shadowed by a closer variable declaration
71
+ * @param {import('jscodeshift').JSCodeshift} j - The jscodeshift API
72
+ * @param {string} varName - The variable name to check
73
+ * @param {import('jscodeshift').ASTPath} declarationPath - The path to the original declaration
74
+ * @param {import('jscodeshift').ASTPath} usagePath - The path to the assignment/update expression
75
+ * @returns {boolean} True if the assignment is shadowed by a closer declaration
76
+ */
77
+ function isAssignmentShadowed(j, varName, declarationPath, usagePath) {
78
+ let current = usagePath.parent
79
+
80
+ while (current) {
81
+ if (
82
+ j.FunctionDeclaration.check(current.node) ||
83
+ j.FunctionExpression.check(current.node) ||
84
+ j.ArrowFunctionExpression.check(current.node)
85
+ ) {
86
+ // Check function parameters
87
+ if (current.node.params) {
88
+ for (const param of current.node.params) {
89
+ if (patternContainsIdentifier(j, param, varName)) {
90
+ return true
91
+ }
92
+ }
93
+ }
94
+
95
+ // Check for var/let/const declarations in this function
96
+ const functionBody = current.node.body
97
+ if (functionBody) {
98
+ let foundOurDeclaration = false
99
+ const hasLocalDecl = j(functionBody)
100
+ .find(j.VariableDeclarator)
101
+ .some((declPath) => {
102
+ const declParent = declPath.parent.node
103
+ if (declParent === declarationPath.node) {
104
+ foundOurDeclaration = true
105
+ return false
106
+ }
107
+ return patternContainsIdentifier(j, declPath.node.id, varName)
108
+ })
109
+
110
+ // If we found a shadowing declaration (not our own), the assignment is shadowed
111
+ if (hasLocalDecl) {
112
+ return true
113
+ }
114
+
115
+ // If we found our declaration in this scope, stop traversing -
116
+ // the assignment is not shadowed, it belongs to our declaration
117
+ if (foundOurDeclaration) {
118
+ return false
119
+ }
120
+ }
121
+ }
122
+
123
+ current = current.parent
124
+ }
125
+
126
+ return false
127
+ }
128
+
129
+ /**
130
+ * Check if a variable is reassigned after its declaration
131
+ * @param {import('jscodeshift').JSCodeshift} j - The jscodeshift API
132
+ * @param {import('jscodeshift').Collection} root - The root AST collection
133
+ * @param {string} varName - The variable name to check
134
+ * @param {import('jscodeshift').ASTPath} declarationPath - The path to the variable declaration
135
+ * @returns {boolean} True if the variable is reassigned
136
+ */
137
+ function isVariableReassigned(j, root, varName, declarationPath) {
138
+ let isReassigned = false
139
+
140
+ // Check for AssignmentExpression where left side targets the variable
141
+ root.find(j.AssignmentExpression).forEach((assignPath) => {
142
+ if (!patternContainsIdentifier(j, assignPath.node.left, varName)) {
143
+ return
144
+ }
145
+
146
+ if (isAssignmentShadowed(j, varName, declarationPath, assignPath)) {
147
+ return
148
+ }
149
+
150
+ isReassigned = true
151
+ })
152
+
153
+ if (isReassigned) return true
154
+
155
+ // Check for UpdateExpression (++, --)
156
+ root.find(j.UpdateExpression).forEach((updatePath) => {
157
+ if (
158
+ !j.Identifier.check(updatePath.node.argument) ||
159
+ updatePath.node.argument.name !== varName
160
+ ) {
161
+ return
162
+ }
163
+
164
+ if (isAssignmentShadowed(j, varName, declarationPath, updatePath)) {
165
+ return
166
+ }
167
+
168
+ isReassigned = true
169
+ })
170
+
171
+ return isReassigned
172
+ }
173
+
174
+ /**
175
+ * Determine the appropriate kind (const or let) for a declarator
176
+ * @param {import('jscodeshift').JSCodeshift} j - The jscodeshift API
177
+ * @param {import('jscodeshift').Collection} root - The root AST collection
178
+ * @param {import('jscodeshift').VariableDeclarator} declarator - The variable declarator
179
+ * @param {import('jscodeshift').ASTPath} declarationPath - The path to the variable declaration
180
+ * @returns {'const' | 'let'} The appropriate variable kind
181
+ */
182
+ function determineDeclaratorKind(j, root, declarator, declarationPath) {
183
+ if (j.Identifier.check(declarator.id)) {
184
+ return isVariableReassigned(j, root, declarator.id.name, declarationPath)
185
+ ? "let"
186
+ : "const"
187
+ }
188
+
189
+ // Destructuring pattern - check if any identifier is reassigned
190
+ for (const varName of extractIdentifiersFromPattern(j, declarator.id)) {
191
+ if (isVariableReassigned(j, root, varName, declarationPath)) {
192
+ return "let"
193
+ }
194
+ }
195
+
196
+ return "const"
197
+ }
198
+
199
+ /**
200
+ * Process a single declarator variable declaration
201
+ * @param {import('jscodeshift').JSCodeshift} j - The jscodeshift API
202
+ * @param {import('jscodeshift').Collection} root - The root AST collection
203
+ * @param {import('jscodeshift').ASTPath} path - The path to the variable declaration
204
+ * @returns {{ modified: boolean, change: { type: string, line: number } | null }}
205
+ */
206
+ function processSingleDeclarator(j, root, path) {
207
+ const declarator = path.node.declarations[0]
208
+ const kind = determineDeclaratorKind(j, root, declarator, path)
209
+
210
+ path.node.kind = kind
211
+
212
+ const change = path.node.loc
213
+ ? { type: "varToLetOrConst", line: path.node.loc.start.line }
214
+ : null
215
+
216
+ return { modified: true, change }
217
+ }
218
+
219
+ /**
220
+ * Process a multiple declarator variable declaration by splitting into separate declarations
221
+ * @param {import('jscodeshift').JSCodeshift} j - The jscodeshift API
222
+ * @param {import('jscodeshift').Collection} root - The root AST collection
223
+ * @param {import('jscodeshift').ASTPath} path - The path to the variable declaration
224
+ * @returns {{ modified: boolean, change: { type: string, line: number } | null }}
225
+ */
226
+ function processMultipleDeclarators(j, root, path) {
227
+ const declarations = path.node.declarations.map((declarator) => {
228
+ const kind = determineDeclaratorKind(j, root, declarator, path)
229
+ return j.variableDeclaration(kind, [declarator])
230
+ })
231
+
232
+ j(path).replaceWith(declarations)
233
+
234
+ const change = path.node.loc
235
+ ? { type: "varToLetOrConst", line: path.node.loc.start.line }
236
+ : null
237
+
238
+ return { modified: true, change }
239
+ }
240
+
241
+ /**
242
+ * Transform var to const or let
3
243
  * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/const
4
244
  * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/let
245
+ * @param {import('jscodeshift').JSCodeshift} j - The jscodeshift API
246
+ * @param {import('jscodeshift').Collection} root - The root AST collection
247
+ * @returns {{ modified: boolean, changes: Array<{ type: string, line: number }> }}
5
248
  */
6
- export function varToConst(j, root) {
249
+ export function varToLetOrConst(j, root) {
7
250
  let modified = false
8
251
  const changes = []
9
252
 
10
253
  root.find(j.VariableDeclaration, { kind: "var" }).forEach((path) => {
11
- path.node.kind = "const"
12
- modified = true
13
- if (path.node.loc) {
14
- changes.push({
15
- type: "varToConst",
16
- line: path.node.loc.start.line,
17
- })
254
+ const isSingleDeclarator = path.node.declarations.length === 1
255
+
256
+ const result = isSingleDeclarator
257
+ ? processSingleDeclarator(j, root, path)
258
+ : processMultipleDeclarators(j, root, path)
259
+
260
+ if (result.modified) {
261
+ modified = true
262
+ }
263
+ if (result.change) {
264
+ changes.push(result.change)
18
265
  }
19
266
  })
20
267
 
package/tests/cli.test.js CHANGED
@@ -274,7 +274,11 @@ describe("CLI", () => {
274
274
  encoding: "utf8",
275
275
  })
276
276
 
277
- assert.match(result.stdout, /var to const/, "shows var to const changes")
277
+ assert.match(
278
+ result.stdout,
279
+ /var to let or const/,
280
+ "shows var to let or const changes",
281
+ )
278
282
  assert.equal(result.status, 1, "exits with 1")
279
283
  })
280
284
 
@@ -357,7 +361,11 @@ describe("CLI", () => {
357
361
  encoding: "utf8",
358
362
  })
359
363
 
360
- assert.match(result.stdout, /var to const/, "shows var to const changes")
364
+ assert.match(
365
+ result.stdout,
366
+ /var to let or const/,
367
+ "shows var to let or const changes",
368
+ )
361
369
  assert.equal(result.status, 1, "exits with 1")
362
370
  })
363
371
 
@@ -397,6 +405,51 @@ describe("CLI", () => {
397
405
  assert.equal(result.status, 1, "exits with 1 on errors without --check")
398
406
  })
399
407
 
408
+ test("dry-run mode without flags shows changes but doesn't write", () => {
409
+ const testFile = path.join(tempDir, "test.js")
410
+ const originalCode = `var x = 1;`
411
+ fs.writeFileSync(testFile, originalCode)
412
+
413
+ const result = spawnSync(process.execPath, [CLI_PATH, testFile], {
414
+ encoding: "utf8",
415
+ })
416
+
417
+ assert.equal(
418
+ fs.readFileSync(testFile, "utf8"),
419
+ originalCode,
420
+ "leaves file unchanged",
421
+ )
422
+ assert.match(result.stdout, /✗/, "indicates changes would be made")
423
+ assert.match(
424
+ result.stdout,
425
+ /1 file would be upgraded/,
426
+ "reports 1 file would be upgraded",
427
+ )
428
+ assert.equal(result.status, 0, "exits with 0 in dry-run mode")
429
+ })
430
+
431
+ test("dry-run mode exits with 0 when no changes needed", () => {
432
+ const testFile = path.join(tempDir, "test.js")
433
+ const originalCode = `const x = 1;`
434
+ fs.writeFileSync(testFile, originalCode)
435
+
436
+ const result = spawnSync(process.execPath, [CLI_PATH, testFile], {
437
+ encoding: "utf8",
438
+ })
439
+
440
+ assert.equal(
441
+ fs.readFileSync(testFile, "utf8"),
442
+ originalCode,
443
+ "leaves file unchanged",
444
+ )
445
+ assert.match(
446
+ result.stdout,
447
+ /All files are up to date/,
448
+ "reports all files up to date",
449
+ )
450
+ assert.equal(result.status, 0, "exits with 0")
451
+ })
452
+
400
453
  test("exit with 1 on errors even with valid files", () => {
401
454
  const validFile = path.join(tempDir, "valid.js")
402
455
  const invalidFile = path.join(tempDir, "invalid.js")
@@ -1,8 +1,8 @@
1
- import { describe, test } from "node:test"
1
+ import { describe, suite, test } from "node:test"
2
2
  import assert from "node:assert/strict"
3
3
  import { transform } from "../src/index.js"
4
4
 
5
- describe("newly-available", () => {
5
+ suite("newly-available", () => {
6
6
  describe("Promise.try", () => {
7
7
  test("transforms resolve call with argument", () => {
8
8
  const result = transform(