@openrewrite/rewrite 8.69.0-20251210-164937 → 8.69.0-20251210-194227

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/dist/index.d.ts.map +1 -1
  2. package/dist/index.js +2 -1
  3. package/dist/index.js.map +1 -1
  4. package/dist/javascript/dependency-manager.d.ts +31 -0
  5. package/dist/javascript/dependency-manager.d.ts.map +1 -0
  6. package/dist/javascript/dependency-manager.js +243 -0
  7. package/dist/javascript/dependency-manager.js.map +1 -0
  8. package/dist/javascript/index.d.ts +1 -0
  9. package/dist/javascript/index.d.ts.map +1 -1
  10. package/dist/javascript/index.js +1 -0
  11. package/dist/javascript/index.js.map +1 -1
  12. package/dist/javascript/package-manager.d.ts +43 -0
  13. package/dist/javascript/package-manager.d.ts.map +1 -1
  14. package/dist/javascript/package-manager.js +85 -0
  15. package/dist/javascript/package-manager.js.map +1 -1
  16. package/dist/javascript/recipes/index.d.ts +1 -0
  17. package/dist/javascript/recipes/index.d.ts.map +1 -1
  18. package/dist/javascript/recipes/index.js +1 -0
  19. package/dist/javascript/recipes/index.js.map +1 -1
  20. package/dist/javascript/recipes/upgrade-dependency-version.d.ts +6 -6
  21. package/dist/javascript/recipes/upgrade-dependency-version.d.ts.map +1 -1
  22. package/dist/javascript/recipes/upgrade-dependency-version.js +51 -86
  23. package/dist/javascript/recipes/upgrade-dependency-version.js.map +1 -1
  24. package/dist/javascript/recipes/upgrade-transitive-dependency-version.d.ts +81 -0
  25. package/dist/javascript/recipes/upgrade-transitive-dependency-version.d.ts.map +1 -0
  26. package/dist/javascript/recipes/upgrade-transitive-dependency-version.js +388 -0
  27. package/dist/javascript/recipes/upgrade-transitive-dependency-version.js.map +1 -0
  28. package/dist/version.txt +1 -1
  29. package/package.json +1 -1
  30. package/src/index.ts +2 -1
  31. package/src/javascript/dependency-manager.ts +292 -0
  32. package/src/javascript/index.ts +1 -0
  33. package/src/javascript/package-manager.ts +111 -0
  34. package/src/javascript/recipes/index.ts +1 -0
  35. package/src/javascript/recipes/upgrade-dependency-version.ts +67 -105
  36. package/src/javascript/recipes/upgrade-transitive-dependency-version.ts +445 -0
@@ -0,0 +1,292 @@
1
+ /*
2
+ * Copyright 2025 the original author or authors.
3
+ * <p>
4
+ * Licensed under the Moderne Source Available License (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ * <p>
8
+ * https://docs.moderne.io/licensing/moderne-source-available-license
9
+ * <p>
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ import {PackageManager} from "./node-resolution-result";
18
+
19
+ /**
20
+ * Parsed dependency path for scoped overrides.
21
+ * Segments represent the chain of dependencies, e.g., "express>accepts" becomes
22
+ * [{name: "express"}, {name: "accepts"}]
23
+ */
24
+ export interface DependencyPathSegment {
25
+ name: string;
26
+ version?: string;
27
+ }
28
+
29
+ /**
30
+ * Parses a dependency path string into segments.
31
+ * Accepts both '>' (pnpm style) and '/' (yarn style) as separators.
32
+ * Examples:
33
+ * "express>accepts" -> [{name: "express"}, {name: "accepts"}]
34
+ * "express@4.0.0>accepts" -> [{name: "express", version: "4.0.0"}, {name: "accepts"}]
35
+ * "@scope/pkg>dep" -> [{name: "@scope/pkg"}, {name: "dep"}]
36
+ */
37
+ export function parseDependencyPath(path: string): DependencyPathSegment[] {
38
+ // We can't just replace all '/' with '>' because scoped packages contain '/'
39
+ // Strategy: Split on '>' first, then for each part that contains '/' and doesn't
40
+ // start with '@', treat it as a '/'-separated path (yarn style)
41
+ const segments: DependencyPathSegment[] = [];
42
+
43
+ // Split on '>' (pnpm style separator)
44
+ const gtParts = path.split('>');
45
+
46
+ for (const gtPart of gtParts) {
47
+ // Check if this part needs further splitting by '/'
48
+ // Only split if it contains '/' AND either:
49
+ // - doesn't start with '@' (not a scoped package), OR
50
+ // - contains multiple '/' (e.g., "@scope/pkg/dep" is yarn-style path)
51
+ if (gtPart.includes('/')) {
52
+ if (gtPart.startsWith('@')) {
53
+ // Scoped package: @scope/pkg or @scope/pkg@version or @scope/pkg/dep (yarn path)
54
+ // Find the first '/' which is part of the scope
55
+ const firstSlash = gtPart.indexOf('/');
56
+ const afterFirstSlash = gtPart.substring(firstSlash + 1);
57
+
58
+ // Check if there's another '/' after the scope (yarn-style nesting)
59
+ const secondSlash = afterFirstSlash.indexOf('/');
60
+ if (secondSlash !== -1) {
61
+ // yarn-style: @scope/pkg/dep - split further
62
+ // First get the scoped package part
63
+ const scopedPart = gtPart.substring(0, firstSlash + 1 + secondSlash);
64
+ segments.push(parseSegment(scopedPart));
65
+
66
+ // Then handle the rest as separate segments
67
+ const rest = afterFirstSlash.substring(secondSlash + 1);
68
+ for (const subPart of rest.split('/')) {
69
+ if (subPart) {
70
+ segments.push(parseSegment(subPart));
71
+ }
72
+ }
73
+ } else {
74
+ // Simple scoped package: @scope/pkg or @scope/pkg@version
75
+ segments.push(parseSegment(gtPart));
76
+ }
77
+ } else {
78
+ // Non-scoped with '/': yarn-style path like "express/accepts"
79
+ for (const slashPart of gtPart.split('/')) {
80
+ if (slashPart) {
81
+ segments.push(parseSegment(slashPart));
82
+ }
83
+ }
84
+ }
85
+ } else {
86
+ // No '/', just parse the segment directly
87
+ segments.push(parseSegment(gtPart));
88
+ }
89
+ }
90
+
91
+ return segments;
92
+ }
93
+
94
+ /**
95
+ * Parses a single segment (package name, possibly with version).
96
+ */
97
+ function parseSegment(part: string): DependencyPathSegment {
98
+ // Handle scoped packages: @scope/name or @scope/name@version
99
+ if (part.startsWith('@')) {
100
+ // Find the version separator (last @ that's not the scope prefix)
101
+ const slashIndex = part.indexOf('/');
102
+ if (slashIndex === -1) {
103
+ return {name: part};
104
+ }
105
+ const afterSlash = part.substring(slashIndex + 1);
106
+ const atIndex = afterSlash.lastIndexOf('@');
107
+ if (atIndex > 0) {
108
+ return {
109
+ name: part.substring(0, slashIndex + 1 + atIndex),
110
+ version: afterSlash.substring(atIndex + 1)
111
+ };
112
+ }
113
+ return {name: part};
114
+ }
115
+
116
+ // Non-scoped package: name or name@version
117
+ const atIndex = part.lastIndexOf('@');
118
+ if (atIndex > 0) {
119
+ return {
120
+ name: part.substring(0, atIndex),
121
+ version: part.substring(atIndex + 1)
122
+ };
123
+ }
124
+ return {name: part};
125
+ }
126
+
127
+ /**
128
+ * Generates an npm-style override entry (nested objects).
129
+ * npm uses nested objects for scoped overrides:
130
+ * { "express": { "accepts": "^2.0.0" } }
131
+ * or for global overrides:
132
+ * { "lodash": "^4.17.21" }
133
+ */
134
+ function generateNpmOverride(
135
+ packageName: string,
136
+ newVersion: string,
137
+ pathSegments?: DependencyPathSegment[]
138
+ ): Record<string, any> {
139
+ if (!pathSegments || pathSegments.length === 0) {
140
+ // Global override
141
+ return {[packageName]: newVersion};
142
+ }
143
+
144
+ // Build nested structure from outside in
145
+ let result: Record<string, any> = {[packageName]: newVersion};
146
+ for (let i = pathSegments.length - 1; i >= 0; i--) {
147
+ const segment = pathSegments[i];
148
+ const key = segment.version ? `${segment.name}@${segment.version}` : segment.name;
149
+ result = {[key]: result};
150
+ }
151
+ return result;
152
+ }
153
+
154
+ /**
155
+ * Generates a Yarn-style resolution entry (path with / separator).
156
+ * Yarn uses a flat object with path keys:
157
+ * { "express/accepts": "^2.0.0" }
158
+ * or for global:
159
+ * { "lodash": "^4.17.21" }
160
+ */
161
+ function generateYarnResolution(
162
+ packageName: string,
163
+ newVersion: string,
164
+ pathSegments?: DependencyPathSegment[]
165
+ ): Record<string, string> {
166
+ if (!pathSegments || pathSegments.length === 0) {
167
+ // Global resolution
168
+ return {[packageName]: newVersion};
169
+ }
170
+
171
+ // Yarn uses / separator: "express/accepts"
172
+ // Note: Yarn only supports one level of nesting, so we use the last segment
173
+ const parentSegment = pathSegments[pathSegments.length - 1];
174
+ const parentKey = parentSegment.version
175
+ ? `${parentSegment.name}@${parentSegment.version}`
176
+ : parentSegment.name;
177
+ const key = `${parentKey}/${packageName}`;
178
+ return {[key]: newVersion};
179
+ }
180
+
181
+ /**
182
+ * Generates a pnpm-style override entry (path with > separator).
183
+ * pnpm uses a flat object with > path keys:
184
+ * { "express>accepts": "^2.0.0" }
185
+ * or for global:
186
+ * { "lodash": "^4.17.21" }
187
+ */
188
+ function generatePnpmOverride(
189
+ packageName: string,
190
+ newVersion: string,
191
+ pathSegments?: DependencyPathSegment[]
192
+ ): Record<string, string> {
193
+ if (!pathSegments || pathSegments.length === 0) {
194
+ // Global override
195
+ return {[packageName]: newVersion};
196
+ }
197
+
198
+ // pnpm uses > separator: "express@1>accepts"
199
+ const pathParts = pathSegments.map(seg =>
200
+ seg.version ? `${seg.name}@${seg.version}` : seg.name
201
+ );
202
+ const key = `${pathParts.join('>')}>${packageName}`;
203
+ return {[key]: newVersion};
204
+ }
205
+
206
+ /**
207
+ * Merges a new override entry into an existing overrides object.
208
+ * Handles npm's nested structure by deep merging.
209
+ */
210
+ function mergeNpmOverride(
211
+ existing: Record<string, any>,
212
+ newOverride: Record<string, any>
213
+ ): Record<string, any> {
214
+ const result = {...existing};
215
+
216
+ for (const [key, value] of Object.entries(newOverride)) {
217
+ if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
218
+ // Deep merge for nested objects
219
+ if (typeof result[key] === 'object' && result[key] !== null) {
220
+ result[key] = mergeNpmOverride(result[key], value);
221
+ } else {
222
+ result[key] = value;
223
+ }
224
+ } else {
225
+ // Simple value, just overwrite
226
+ result[key] = value;
227
+ }
228
+ }
229
+
230
+ return result;
231
+ }
232
+
233
+ /**
234
+ * Merges a new resolution/override entry into an existing flat object.
235
+ * Used for Yarn resolutions and pnpm overrides.
236
+ */
237
+ function mergeFlatOverride(
238
+ existing: Record<string, string>,
239
+ newOverride: Record<string, string>
240
+ ): Record<string, string> {
241
+ return {...existing, ...newOverride};
242
+ }
243
+
244
+ /**
245
+ * Applies an override to a package.json object based on the package manager.
246
+ *
247
+ * @param packageJson The parsed package.json object
248
+ * @param packageManager The package manager in use
249
+ * @param packageName The target package to override
250
+ * @param newVersion The version to set
251
+ * @param pathSegments Optional path segments for scoped override
252
+ * @returns The modified package.json object
253
+ */
254
+ export function applyOverrideToPackageJson(
255
+ packageJson: Record<string, any>,
256
+ packageManager: PackageManager,
257
+ packageName: string,
258
+ newVersion: string,
259
+ pathSegments?: DependencyPathSegment[]
260
+ ): Record<string, any> {
261
+ const result = {...packageJson};
262
+
263
+ switch (packageManager) {
264
+ case PackageManager.Npm:
265
+ case PackageManager.Bun: {
266
+ // npm and Bun use "overrides" with nested objects
267
+ const newOverride = generateNpmOverride(packageName, newVersion, pathSegments);
268
+ result.overrides = mergeNpmOverride(result.overrides || {}, newOverride);
269
+ break;
270
+ }
271
+
272
+ case PackageManager.YarnClassic:
273
+ case PackageManager.YarnBerry: {
274
+ // Yarn uses "resolutions" with flat path keys
275
+ const newResolution = generateYarnResolution(packageName, newVersion, pathSegments);
276
+ result.resolutions = mergeFlatOverride(result.resolutions || {}, newResolution);
277
+ break;
278
+ }
279
+
280
+ case PackageManager.Pnpm: {
281
+ // pnpm uses "pnpm.overrides" with > path keys
282
+ const newOverride = generatePnpmOverride(packageName, newVersion, pathSegments);
283
+ if (!result.pnpm) {
284
+ result.pnpm = {};
285
+ }
286
+ result.pnpm.overrides = mergeFlatOverride(result.pnpm?.overrides || {}, newOverride);
287
+ break;
288
+ }
289
+ }
290
+
291
+ return result;
292
+ }
@@ -22,6 +22,7 @@ export * from "./markers";
22
22
  export * from "./node-resolution-result";
23
23
  export * from "./package-json-parser";
24
24
  export * from "./package-manager";
25
+ export * from "./dependency-manager";
25
26
  export * from "./preconditions";
26
27
  export * from "./templating/index";
27
28
  export * from "./method-matcher";
@@ -16,7 +16,9 @@
16
16
 
17
17
  import {PackageManager} from "./node-resolution-result";
18
18
  import * as fs from "fs";
19
+ import * as fsp from "fs/promises";
19
20
  import * as path from "path";
21
+ import * as os from "os";
20
22
  import {spawnSync} from "child_process";
21
23
 
22
24
  /**
@@ -426,3 +428,112 @@ export function getPackageManagerDisplayName(pm: PackageManager): string {
426
428
  return 'Bun';
427
429
  }
428
430
  }
431
+
432
+ /**
433
+ * Result of running install in a temporary directory.
434
+ */
435
+ export interface TempInstallResult {
436
+ /** Whether the install succeeded */
437
+ success: boolean;
438
+ /** The updated lock file content (if successful and lock file exists) */
439
+ lockFileContent?: string;
440
+ /** Error message (if failed) */
441
+ error?: string;
442
+ }
443
+
444
+ /**
445
+ * Options for running install in a temporary directory.
446
+ */
447
+ export interface TempInstallOptions {
448
+ /** Timeout in milliseconds (default: 120000 = 2 minutes) */
449
+ timeout?: number;
450
+ /**
451
+ * If true, only update the lock file without installing node_modules.
452
+ * If false, perform a full install which creates node_modules in the temp dir.
453
+ * Default: true (lock-only is faster and sufficient for most cases)
454
+ */
455
+ lockOnly?: boolean;
456
+ }
457
+
458
+ /**
459
+ * Runs package manager install in a temporary directory.
460
+ *
461
+ * This function:
462
+ * 1. Creates a temp directory
463
+ * 2. Writes the provided package.json content
464
+ * 3. Copies the existing lock file (if present)
465
+ * 4. Copies config files (.npmrc, .yarnrc, etc.)
466
+ * 5. Runs the package manager install
467
+ * 6. Returns the updated lock file content
468
+ * 7. Cleans up the temp directory
469
+ *
470
+ * @param projectDir The original project directory (for copying lock file and configs)
471
+ * @param pm The package manager to use
472
+ * @param modifiedPackageJson The modified package.json content to use
473
+ * @param options Optional settings for timeout and lock-only mode
474
+ * @returns Result containing success status and lock file content or error
475
+ */
476
+ export async function runInstallInTempDir(
477
+ projectDir: string,
478
+ pm: PackageManager,
479
+ modifiedPackageJson: string,
480
+ options: TempInstallOptions = {}
481
+ ): Promise<TempInstallResult> {
482
+ const {timeout = 120000, lockOnly = true} = options;
483
+ const lockFileName = getLockFileName(pm);
484
+ const tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'openrewrite-pm-'));
485
+
486
+ try {
487
+ // Write modified package.json to temp directory
488
+ await fsp.writeFile(path.join(tempDir, 'package.json'), modifiedPackageJson);
489
+
490
+ // Copy existing lock file if present
491
+ const originalLockPath = path.join(projectDir, lockFileName);
492
+ if (fs.existsSync(originalLockPath)) {
493
+ await fsp.copyFile(originalLockPath, path.join(tempDir, lockFileName));
494
+ }
495
+
496
+ // Copy config files if present (for registry configuration and workspace setup)
497
+ const configFiles = ['.npmrc', '.yarnrc', '.yarnrc.yml', '.pnpmfile.cjs', 'pnpm-workspace.yaml'];
498
+ for (const configFile of configFiles) {
499
+ const configPath = path.join(projectDir, configFile);
500
+ if (fs.existsSync(configPath)) {
501
+ await fsp.copyFile(configPath, path.join(tempDir, configFile));
502
+ }
503
+ }
504
+
505
+ // Run package manager install
506
+ const result = runInstall(pm, {
507
+ cwd: tempDir,
508
+ lockOnly,
509
+ timeout
510
+ });
511
+
512
+ if (!result.success) {
513
+ return {
514
+ success: false,
515
+ error: result.error || result.stderr || 'Unknown error'
516
+ };
517
+ }
518
+
519
+ // Read back the updated lock file
520
+ const updatedLockPath = path.join(tempDir, lockFileName);
521
+ let lockFileContent: string | undefined;
522
+ if (fs.existsSync(updatedLockPath)) {
523
+ lockFileContent = await fsp.readFile(updatedLockPath, 'utf-8');
524
+ }
525
+
526
+ return {
527
+ success: true,
528
+ lockFileContent
529
+ };
530
+
531
+ } finally {
532
+ // Cleanup temp directory
533
+ try {
534
+ await fsp.rm(tempDir, {recursive: true, force: true});
535
+ } catch {
536
+ // Ignore cleanup errors
537
+ }
538
+ }
539
+ }
@@ -17,5 +17,6 @@
17
17
  export * from "./async-callback-in-sync-array-method";
18
18
  export * from "./auto-format";
19
19
  export * from "./upgrade-dependency-version";
20
+ export * from "./upgrade-transitive-dependency-version";
20
21
  export * from "./order-imports";
21
22
  export * from "./change-import";
@@ -26,18 +26,11 @@ import {
26
26
  PackageManager,
27
27
  readNpmrcConfigs
28
28
  } from "../node-resolution-result";
29
- import * as fs from "fs";
30
- import * as fsp from "fs/promises";
31
29
  import * as path from "path";
32
- import * as os from "os";
33
30
  import * as semver from "semver";
34
31
  import {markupWarn, replaceMarkerByKind} from "../../markers";
35
32
  import {TreePrinters} from "../../print";
36
- import {
37
- getAllLockFileNames,
38
- getLockFileName,
39
- runInstall
40
- } from "../package-manager";
33
+ import {getAllLockFileNames, getLockFileName, runInstallInTempDir} from "../package-manager";
41
34
 
42
35
  /**
43
36
  * Represents a dependency scope in package.json
@@ -90,7 +83,7 @@ interface Accumulator {
90
83
  }
91
84
 
92
85
  /**
93
- * Upgrades the version of a dependency in package.json and updates the lock file.
86
+ * Upgrades the version of a direct dependency in package.json and updates the lock file.
94
87
  *
95
88
  * This recipe:
96
89
  * 1. Finds package.json files containing the specified dependency
@@ -98,15 +91,15 @@ interface Accumulator {
98
91
  * 3. Runs the package manager to update the lock file
99
92
  * 4. Updates the NodeResolutionResult marker with new dependency info
100
93
  *
101
- * TODO: Consider adding a `resolveToLatestMatching` option that would use `npm install <pkg>@<version>`
102
- * to let the package manager resolve the constraint to the latest matching version.
103
- * For example, `^22.0.0` would become `^22.19.1` (the latest version satisfying ^22.0.0).
104
- * This would be similar to how Maven's UpgradeDependencyVersion works with version selectors.
94
+ * For upgrading transitive dependencies (those pulled in indirectly by your direct
95
+ * dependencies), use `UpgradeTransitiveDependencyVersion` instead.
96
+ *
97
+ * @see UpgradeTransitiveDependencyVersion for transitive dependencies
105
98
  */
106
99
  export class UpgradeDependencyVersion extends ScanningRecipe<Accumulator> {
107
100
  readonly name = "org.openrewrite.javascript.dependencies.upgrade-dependency-version";
108
101
  readonly displayName = "Upgrade npm dependency version";
109
- readonly description = "Upgrades the version of a dependency in `package.json` and updates the lock file by running the package manager.";
102
+ readonly description = "Upgrades the version of a direct dependency in `package.json` and updates the lock file by running the package manager.";
110
103
 
111
104
  @Option({
112
105
  displayName: "Package name",
@@ -169,7 +162,7 @@ export class UpgradeDependencyVersion extends ScanningRecipe<Accumulator> {
169
162
  const recipe = this;
170
163
 
171
164
  return new class extends JsonVisitor<ExecutionContext> {
172
- protected async visitDocument(doc: Json.Document, ctx: ExecutionContext): Promise<Json | undefined> {
165
+ protected async visitDocument(doc: Json.Document, _ctx: ExecutionContext): Promise<Json | undefined> {
173
166
  // Only process package.json files
174
167
  if (!doc.sourcePath.endsWith('package.json')) {
175
168
  return doc;
@@ -180,48 +173,54 @@ export class UpgradeDependencyVersion extends ScanningRecipe<Accumulator> {
180
173
  return doc;
181
174
  }
182
175
 
176
+ // Get the project directory and package manager
177
+ const projectDir = path.dirname(path.resolve(doc.sourcePath));
178
+ const pm = marker.packageManager ?? PackageManager.Npm;
179
+
183
180
  // Check each dependency scope for the target package
184
181
  const scopes: DependencyScope[] = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'];
182
+ let foundScope: DependencyScope | undefined;
183
+ let currentVersion: string | undefined;
185
184
 
186
185
  for (const scope of scopes) {
187
186
  const deps = marker[scope];
188
187
  const dep = deps?.find(d => d.name === recipe.packageName);
189
188
 
190
189
  if (dep) {
191
- const currentVersion = dep.versionConstraint;
192
-
193
- // Check if version needs updating using semver comparison
194
- // Only upgrade if the new version is strictly newer than current
195
- if (recipe.shouldUpgrade(currentVersion, recipe.newVersion)) {
196
- // Get the project directory from the marker path
197
- const projectDir = path.dirname(path.resolve(doc.sourcePath));
198
-
199
- // Use package manager from marker (set during parsing), default to npm
200
- const pm = marker.packageManager ?? PackageManager.Npm;
201
-
202
- // Check if the resolved version already satisfies the new constraint.
203
- // If so, we can skip running the package manager entirely.
204
- const resolvedDep = marker.resolvedDependencies?.find(
205
- rd => rd.name === recipe.packageName
206
- );
207
- const skipInstall = resolvedDep !== undefined &&
208
- semver.satisfies(resolvedDep.version, recipe.newVersion);
209
-
210
- acc.projectsToUpdate.set(doc.sourcePath, {
211
- projectDir,
212
- packageJsonPath: doc.sourcePath,
213
- originalPackageJson: await this.printDocument(doc),
214
- dependencyScope: scope,
215
- currentVersion,
216
- newVersion: recipe.newVersion,
217
- packageManager: pm,
218
- skipInstall
219
- });
220
- }
221
- break; // Found the dependency, no need to check other scopes
190
+ foundScope = scope;
191
+ currentVersion = dep.versionConstraint;
192
+ break;
222
193
  }
223
194
  }
224
195
 
196
+ if (!foundScope || !currentVersion) {
197
+ return doc; // Dependency not found in any scope
198
+ }
199
+
200
+ // Check if upgrade is needed
201
+ if (!recipe.shouldUpgrade(currentVersion, recipe.newVersion)) {
202
+ return doc; // Already at target version or newer
203
+ }
204
+
205
+ // Check if we can skip running the package manager
206
+ // (resolved version already satisfies the new constraint)
207
+ const resolvedDep = marker.resolvedDependencies?.find(
208
+ rd => rd.name === recipe.packageName
209
+ );
210
+ const skipInstall = resolvedDep !== undefined &&
211
+ semver.satisfies(resolvedDep.version, recipe.newVersion);
212
+
213
+ acc.projectsToUpdate.set(doc.sourcePath, {
214
+ projectDir,
215
+ packageJsonPath: doc.sourcePath,
216
+ originalPackageJson: await this.printDocument(doc),
217
+ dependencyScope: foundScope,
218
+ currentVersion,
219
+ newVersion: recipe.newVersion,
220
+ packageManager: pm,
221
+ skipInstall
222
+ });
223
+
225
224
  return doc;
226
225
  }
227
226
 
@@ -331,69 +330,32 @@ export class UpgradeDependencyVersion extends ScanningRecipe<Accumulator> {
331
330
  updateInfo: ProjectUpdateInfo,
332
331
  _ctx: ExecutionContext
333
332
  ): Promise<void> {
334
- const pm = updateInfo.packageManager;
335
- const lockFileName = getLockFileName(pm);
336
-
337
- // Create temp directory
338
- const tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'openrewrite-pm-'));
339
-
340
- try {
341
- // Create modified package.json with the new version constraint
342
- const modifiedPackageJson = this.createModifiedPackageJson(
343
- updateInfo.originalPackageJson,
344
- updateInfo.dependencyScope,
345
- updateInfo.newVersion
346
- );
347
-
348
- // Write modified package.json to temp directory
349
- await fsp.writeFile(path.join(tempDir, 'package.json'), modifiedPackageJson);
350
-
351
- // Copy existing lock file if present
352
- const originalLockPath = path.join(updateInfo.projectDir, lockFileName);
353
- if (fs.existsSync(originalLockPath)) {
354
- await fsp.copyFile(originalLockPath, path.join(tempDir, lockFileName));
355
- }
356
-
357
- // Copy config files if present (for registry configuration and workspace setup)
358
- const configFiles = ['.npmrc', '.yarnrc', '.yarnrc.yml', '.pnpmfile.cjs', 'pnpm-workspace.yaml'];
359
- for (const configFile of configFiles) {
360
- const configPath = path.join(updateInfo.projectDir, configFile);
361
- if (fs.existsSync(configPath)) {
362
- await fsp.copyFile(configPath, path.join(tempDir, configFile));
363
- }
364
- }
365
-
366
- // Run package manager install to validate the version and update lock file
367
- const result = runInstall(pm, {
368
- cwd: tempDir,
369
- lockOnly: true,
370
- timeout: 120000 // 2 minute timeout
371
- });
333
+ // Create modified package.json with the new version constraint
334
+ const modifiedPackageJson = this.createModifiedPackageJson(
335
+ updateInfo.originalPackageJson,
336
+ updateInfo.dependencyScope,
337
+ updateInfo.newVersion
338
+ );
372
339
 
373
- if (result.success) {
374
- // Store the modified package.json (we'll use our visitor for actual output)
375
- acc.updatedPackageJsons.set(updateInfo.packageJsonPath, modifiedPackageJson);
340
+ const result = await runInstallInTempDir(
341
+ updateInfo.projectDir,
342
+ updateInfo.packageManager,
343
+ modifiedPackageJson
344
+ );
376
345
 
377
- // Read back the updated lock file
378
- const updatedLockPath = path.join(tempDir, lockFileName);
379
- if (fs.existsSync(updatedLockPath)) {
380
- const updatedLockContent = await fsp.readFile(updatedLockPath, 'utf-8');
381
- const lockFilePath = updateInfo.packageJsonPath.replace('package.json', lockFileName);
382
- acc.updatedLockFiles.set(lockFilePath, updatedLockContent);
383
- }
384
- } else {
385
- // Track the failure - don't update package.json, the version likely doesn't exist
386
- const errorMessage = result.error || result.stderr || 'Unknown error';
387
- acc.failedProjects.set(updateInfo.packageJsonPath, errorMessage);
388
- }
346
+ if (result.success) {
347
+ // Store the modified package.json (we'll use our visitor for actual output)
348
+ acc.updatedPackageJsons.set(updateInfo.packageJsonPath, modifiedPackageJson);
389
349
 
390
- } finally {
391
- // Cleanup temp directory
392
- try {
393
- await fsp.rm(tempDir, {recursive: true, force: true});
394
- } catch {
395
- // Ignore cleanup errors
350
+ // Store the updated lock file content
351
+ if (result.lockFileContent) {
352
+ const lockFileName = getLockFileName(updateInfo.packageManager);
353
+ const lockFilePath = updateInfo.packageJsonPath.replace('package.json', lockFileName);
354
+ acc.updatedLockFiles.set(lockFilePath, result.lockFileContent);
396
355
  }
356
+ } else {
357
+ // Track the failure - don't update package.json, the version likely doesn't exist
358
+ acc.failedProjects.set(updateInfo.packageJsonPath, result.error || 'Unknown error');
397
359
  }
398
360
  }
399
361