@@ -30,6 +30,7 @@ import {
3030} from "../node-resolution-result" ;
3131import * as path from "path" ;
3232import * as semver from "semver" ;
33+ import * as picomatch from "picomatch" ;
3334import { markupWarn , replaceMarkerByKind } from "../../markers" ;
3435import { TreePrinters } from "../../print" ;
3536import {
@@ -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+
5155interface 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> {
9284export 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