Skip to content

Commit 5082a11

Browse files
UpgradeDependencyVersion to support package patterns (#7198)
1 parent e21cc35 commit 5082a11

2 files changed

Lines changed: 200 additions & 85 deletions

File tree

rewrite-javascript/rewrite/src/javascript/recipes/upgrade-dependency-version.ts

Lines changed: 92 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
} from "../node-resolution-result";
3131
import * as path from "path";
3232
import * as semver from "semver";
33+
import * as picomatch from "picomatch";
3334
import {markupWarn, replaceMarkerByKind} from "../../markers";
3435
import {TreePrinters} from "../../print";
3536
import {
@@ -45,28 +46,19 @@ import {
4546
updateNodeResolutionMarker
4647
} from "../package-manager";
4748

48-
/**
49-
* Information about a project that needs updating
50-
*/
49+
interface MatchedDependency {
50+
packageName: string;
51+
dependencyScope: DependencyScope;
52+
currentVersion: string;
53+
}
54+
5155
interface ProjectUpdateInfo {
52-
/** Relative path to package.json (from source root) */
5356
packageJsonPath: string;
54-
/** Original package.json content */
5557
originalPackageJson: string;
56-
/** The scope where the dependency was found */
57-
dependencyScope: DependencyScope;
58-
/** Current version constraint */
59-
currentVersion: string;
60-
/** New version constraint to apply */
58+
matchedDependencies: MatchedDependency[];
6159
newVersion: string;
62-
/** The package manager used by this project */
6360
packageManager: PackageManager;
64-
/**
65-
* If true, skip running the package manager because the resolved version
66-
* already satisfies the new constraint. Only package.json needs updating.
67-
*/
6861
skipInstall: boolean;
69-
/** Config file contents extracted from the project (e.g., .npmrc) */
7062
configFiles?: Record<string, string>;
7163
}
7264

@@ -92,14 +84,23 @@ interface Accumulator extends DependencyRecipeAccumulator<ProjectUpdateInfo> {
9284
export class UpgradeDependencyVersion extends ScanningRecipe<Accumulator> {
9385
readonly name = "org.openrewrite.javascript.dependencies.upgrade-dependency-version";
9486
readonly displayName = "Upgrade npm dependency version";
95-
readonly description = "Upgrades the version of a direct dependency in `package.json` and updates the lock file by running the package manager.";
87+
readonly description = "Upgrades the version of a direct dependency in `package.json` and updates the lock file by running the package manager. Either `packageName` or `packagePattern` must be specified.";
9688

9789
@Option({
9890
displayName: "Package name",
99-
description: "The name of the npm package to upgrade (e.g., `lodash`, `@types/node`)",
91+
description: "The exact name of the npm package to upgrade (e.g., `lodash`, `@types/node`). Either this or `packagePattern` must be specified.",
92+
required: false,
10093
example: "lodash"
10194
})
102-
packageName!: string;
95+
packageName?: string;
96+
97+
@Option({
98+
displayName: "Package pattern",
99+
description: "A glob expression to match package names (e.g., `@angular/*`, `@types/*`). Either this or `packageName` must be specified.",
100+
required: false,
101+
example: "@angular/*"
102+
})
103+
packagePattern?: string;
103104

104105
@Option({
105106
displayName: "Version",
@@ -108,7 +109,31 @@ export class UpgradeDependencyVersion extends ScanningRecipe<Accumulator> {
108109
})
109110
newVersion!: string;
110111

112+
private _matcher?: picomatch.Matcher;
113+
114+
private get matcher(): picomatch.Matcher | undefined {
115+
if (this.packagePattern && !this._matcher) {
116+
this._matcher = picomatch.default
117+
? picomatch.default(this.packagePattern)
118+
: (picomatch as any)(this.packagePattern);
119+
}
120+
return this._matcher;
121+
}
122+
123+
matchesPackage(name: string): boolean {
124+
if (this.packageName && name === this.packageName) {
125+
return true;
126+
}
127+
if (this.matcher) {
128+
return this.matcher(name);
129+
}
130+
return false;
131+
}
132+
111133
initialValue(_ctx: ExecutionContext): Accumulator {
134+
if (!this.packageName && !this.packagePattern) {
135+
throw new Error("Either packageName or packagePattern must be specified");
136+
}
112137
return {
113138
...createDependencyRecipeAccumulator<ProjectUpdateInfo>(),
114139
originalLockFiles: new Map()
@@ -193,40 +218,30 @@ export class UpgradeDependencyVersion extends ScanningRecipe<Accumulator> {
193218

194219
const pm = marker.packageManager ?? PackageManager.Npm;
195220

196-
// Check each dependency scope for the target package
197-
const scopes = allDependencyScopes;
198-
let foundScope: DependencyScope | undefined;
199-
let currentVersion: string | undefined;
200-
201-
for (const scope of scopes) {
221+
const matchedDeps: MatchedDependency[] = [];
222+
for (const scope of allDependencyScopes) {
202223
const deps = marker[scope];
203-
const dep = deps?.find(d => d.name === recipe.packageName);
204-
205-
if (dep) {
206-
foundScope = scope;
207-
currentVersion = dep.versionConstraint;
208-
break;
224+
if (!deps) continue;
225+
for (const dep of deps) {
226+
if (recipe.matchesPackage(dep.name) && recipe.shouldUpgrade(dep.versionConstraint, recipe.newVersion)) {
227+
matchedDeps.push({
228+
packageName: dep.name,
229+
dependencyScope: scope,
230+
currentVersion: dep.versionConstraint
231+
});
232+
}
209233
}
210234
}
211235

212-
if (!foundScope || !currentVersion) {
213-
return doc; // Dependency not found in any scope
214-
}
215-
216-
// Check if upgrade is needed
217-
if (!recipe.shouldUpgrade(currentVersion, recipe.newVersion)) {
218-
return doc; // Already at target version or newer
236+
if (matchedDeps.length === 0) {
237+
return doc;
219238
}
220239

221-
// Check if we can skip running the package manager
222-
// (resolved version already satisfies the new constraint)
223-
const resolvedDep = marker.resolvedDependencies?.find(
224-
rd => rd.name === recipe.packageName
225-
);
226-
const skipInstall = resolvedDep !== undefined &&
227-
semver.satisfies(resolvedDep.version, recipe.newVersion);
240+
const skipInstall = matchedDeps.every(md => {
241+
const resolvedDep = marker.resolvedDependencies?.find(rd => rd.name === md.packageName);
242+
return resolvedDep !== undefined && semver.satisfies(resolvedDep.version, recipe.newVersion);
243+
});
228244

229-
// Serialize npmrc configs from marker using requested scopes
230245
const configFiles: Record<string, string> = {};
231246
const npmrcContent = serializeNpmrcConfigs(marker.npmrcConfigs);
232247
if (npmrcContent) {
@@ -236,8 +251,7 @@ export class UpgradeDependencyVersion extends ScanningRecipe<Accumulator> {
236251
acc.projectsToUpdate.set(doc.sourcePath, {
237252
packageJsonPath: doc.sourcePath,
238253
originalPackageJson: await TreePrinters.print(doc),
239-
dependencyScope: foundScope,
240-
currentVersion,
254+
matchedDependencies: matchedDeps,
241255
newVersion: recipe.newVersion,
242256
packageManager: pm,
243257
skipInstall,
@@ -280,32 +294,31 @@ export class UpgradeDependencyVersion extends ScanningRecipe<Accumulator> {
280294
return doc; // This package.json doesn't need updating
281295
}
282296

283-
// Run package manager install if needed, check for failure
284-
// Skip if the resolved version already satisfies the new constraint
285297
const failureMessage = updateInfo.skipInstall
286298
? undefined
287299
: await runInstallIfNeeded(sourcePath, acc, () =>
288300
recipe.runPackageManagerInstall(acc, updateInfo, ctx)
289301
);
290302
if (failureMessage) {
303+
const names = updateInfo.matchedDependencies.map(d => d.packageName).join(', ');
291304
return markupWarn(
292305
doc,
293-
`Failed to upgrade ${recipe.packageName} to ${recipe.newVersion}`,
306+
`Failed to upgrade ${names} to ${recipe.newVersion}`,
294307
failureMessage
295308
);
296309
}
297310

298-
// Update the dependency version in the JSON AST (preserves formatting)
299-
const visitor = new UpdateVersionVisitor(
300-
recipe.packageName,
301-
updateInfo.newVersion,
302-
updateInfo.dependencyScope
303-
);
304-
const modifiedDoc = await visitor.visit(doc, undefined) as Json.Document;
311+
let modifiedDoc = doc;
312+
for (const md of updateInfo.matchedDependencies) {
313+
const visitor = new UpdateVersionVisitor(
314+
md.packageName,
315+
updateInfo.newVersion,
316+
md.dependencyScope
317+
);
318+
modifiedDoc = await visitor.visit(modifiedDoc, undefined) as Json.Document;
319+
}
305320

306-
// Update the NodeResolutionResult marker
307321
if (updateInfo.skipInstall) {
308-
// Just update the versionConstraint in the marker - resolved version is unchanged
309322
return recipe.updateMarkerVersionConstraint(modifiedDoc, updateInfo);
310323
}
311324
return updateNodeResolutionMarker(modifiedDoc, updateInfo, acc);
@@ -344,10 +357,9 @@ export class UpgradeDependencyVersion extends ScanningRecipe<Accumulator> {
344357
updateInfo: ProjectUpdateInfo,
345358
_ctx: ExecutionContext
346359
): Promise<void> {
347-
// Create modified package.json with the new version constraint
348360
const modifiedPackageJson = this.createModifiedPackageJson(
349361
updateInfo.originalPackageJson,
350-
updateInfo.dependencyScope,
362+
updateInfo.matchedDependencies,
351363
updateInfo.newVersion
352364
);
353365

@@ -373,28 +385,22 @@ export class UpgradeDependencyVersion extends ScanningRecipe<Accumulator> {
373385
storeInstallResult(result, acc, updateInfo, modifiedPackageJson);
374386
}
375387

376-
/**
377-
* Creates a modified package.json with the updated dependency version.
378-
* Used for the temp directory to validate the version exists.
379-
*/
380388
private createModifiedPackageJson(
381389
originalContent: string,
382-
scope: DependencyScope,
390+
matchedDependencies: MatchedDependency[],
383391
newVersion: string
384392
): string {
385393
const packageJson = JSON.parse(originalContent);
386394

387-
if (packageJson[scope] && packageJson[scope][this.packageName]) {
388-
packageJson[scope][this.packageName] = newVersion;
395+
for (const md of matchedDependencies) {
396+
if (packageJson[md.dependencyScope]?.[md.packageName]) {
397+
packageJson[md.dependencyScope][md.packageName] = newVersion;
398+
}
389399
}
390400

391401
return JSON.stringify(packageJson, null, 2);
392402
}
393403

394-
/**
395-
* Updates just the versionConstraint in the marker for the target dependency.
396-
* Used when skipInstall is true - the resolved version is unchanged.
397-
*/
398404
private updateMarkerVersionConstraint(
399405
doc: Json.Document,
400406
updateInfo: ProjectUpdateInfo
@@ -404,18 +410,19 @@ export class UpgradeDependencyVersion extends ScanningRecipe<Accumulator> {
404410
return doc;
405411
}
406412

407-
// Update the versionConstraint for the target dependency
408-
const deps = existingMarker[updateInfo.dependencyScope];
409-
const updatedDeps = deps?.map(dep =>
410-
dep.name === this.packageName
411-
? {...dep, versionConstraint: updateInfo.newVersion}
412-
: dep
413-
);
414-
415-
const newMarker = {
416-
...existingMarker,
417-
[updateInfo.dependencyScope]: updatedDeps
418-
};
413+
const matchedNames = new Set(updateInfo.matchedDependencies.map(md => md.packageName));
414+
let newMarker = {...existingMarker};
415+
for (const md of updateInfo.matchedDependencies) {
416+
const deps = newMarker[md.dependencyScope];
417+
newMarker = {
418+
...newMarker,
419+
[md.dependencyScope]: deps?.map(dep =>
420+
matchedNames.has(dep.name)
421+
? {...dep, versionConstraint: updateInfo.newVersion}
422+
: dep
423+
)
424+
};
425+
}
419426

420427
return {
421428
...doc,

0 commit comments

Comments
 (0)