From c722e45beef2c5de92f4b3cfd1b602c7bce1d8b8 Mon Sep 17 00:00:00 2001 From: Greg Oledzki Date: Mon, 30 Mar 2026 08:23:51 +0200 Subject: [PATCH] UpgradeDependencyVersion to support package patterns --- .../recipes/upgrade-dependency-version.ts | 177 +++++++++--------- .../upgrade-dependency-version.test.ts | 108 +++++++++++ 2 files changed, 200 insertions(+), 85 deletions(-) diff --git a/rewrite-javascript/rewrite/src/javascript/recipes/upgrade-dependency-version.ts b/rewrite-javascript/rewrite/src/javascript/recipes/upgrade-dependency-version.ts index 387e57ba4e1..9873b488de7 100644 --- a/rewrite-javascript/rewrite/src/javascript/recipes/upgrade-dependency-version.ts +++ b/rewrite-javascript/rewrite/src/javascript/recipes/upgrade-dependency-version.ts @@ -30,6 +30,7 @@ import { } from "../node-resolution-result"; import * as path from "path"; import * as semver from "semver"; +import * as picomatch from "picomatch"; import {markupWarn, replaceMarkerByKind} from "../../markers"; import {TreePrinters} from "../../print"; import { @@ -45,28 +46,19 @@ import { updateNodeResolutionMarker } from "../package-manager"; -/** - * Information about a project that needs updating - */ +interface MatchedDependency { + packageName: string; + dependencyScope: DependencyScope; + currentVersion: string; +} + interface ProjectUpdateInfo { - /** Relative path to package.json (from source root) */ packageJsonPath: string; - /** Original package.json content */ originalPackageJson: string; - /** The scope where the dependency was found */ - dependencyScope: DependencyScope; - /** Current version constraint */ - currentVersion: string; - /** New version constraint to apply */ + matchedDependencies: MatchedDependency[]; newVersion: string; - /** The package manager used by this project */ packageManager: PackageManager; - /** - * If true, skip running the package manager because the resolved version - * already satisfies the new constraint. Only package.json needs updating. - */ skipInstall: boolean; - /** Config file contents extracted from the project (e.g., .npmrc) */ configFiles?: Record; } @@ -92,14 +84,23 @@ interface Accumulator extends DependencyRecipeAccumulator { export class UpgradeDependencyVersion extends ScanningRecipe { readonly name = "org.openrewrite.javascript.dependencies.upgrade-dependency-version"; readonly displayName = "Upgrade npm dependency version"; - readonly description = "Upgrades the version of a direct dependency in `package.json` and updates the lock file by running the package manager."; + 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."; @Option({ displayName: "Package name", - description: "The name of the npm package to upgrade (e.g., `lodash`, `@types/node`)", + description: "The exact name of the npm package to upgrade (e.g., `lodash`, `@types/node`). Either this or `packagePattern` must be specified.", + required: false, example: "lodash" }) - packageName!: string; + packageName?: string; + + @Option({ + displayName: "Package pattern", + description: "A glob expression to match package names (e.g., `@angular/*`, `@types/*`). Either this or `packageName` must be specified.", + required: false, + example: "@angular/*" + }) + packagePattern?: string; @Option({ displayName: "Version", @@ -108,7 +109,31 @@ export class UpgradeDependencyVersion extends ScanningRecipe { }) newVersion!: string; + private _matcher?: picomatch.Matcher; + + private get matcher(): picomatch.Matcher | undefined { + if (this.packagePattern && !this._matcher) { + this._matcher = picomatch.default + ? picomatch.default(this.packagePattern) + : (picomatch as any)(this.packagePattern); + } + return this._matcher; + } + + matchesPackage(name: string): boolean { + if (this.packageName && name === this.packageName) { + return true; + } + if (this.matcher) { + return this.matcher(name); + } + return false; + } + initialValue(_ctx: ExecutionContext): Accumulator { + if (!this.packageName && !this.packagePattern) { + throw new Error("Either packageName or packagePattern must be specified"); + } return { ...createDependencyRecipeAccumulator(), originalLockFiles: new Map() @@ -193,40 +218,30 @@ export class UpgradeDependencyVersion extends ScanningRecipe { const pm = marker.packageManager ?? PackageManager.Npm; - // Check each dependency scope for the target package - const scopes = allDependencyScopes; - let foundScope: DependencyScope | undefined; - let currentVersion: string | undefined; - - for (const scope of scopes) { + const matchedDeps: MatchedDependency[] = []; + for (const scope of allDependencyScopes) { const deps = marker[scope]; - const dep = deps?.find(d => d.name === recipe.packageName); - - if (dep) { - foundScope = scope; - currentVersion = dep.versionConstraint; - break; + if (!deps) continue; + for (const dep of deps) { + if (recipe.matchesPackage(dep.name) && recipe.shouldUpgrade(dep.versionConstraint, recipe.newVersion)) { + matchedDeps.push({ + packageName: dep.name, + dependencyScope: scope, + currentVersion: dep.versionConstraint + }); + } } } - if (!foundScope || !currentVersion) { - return doc; // Dependency not found in any scope - } - - // Check if upgrade is needed - if (!recipe.shouldUpgrade(currentVersion, recipe.newVersion)) { - return doc; // Already at target version or newer + if (matchedDeps.length === 0) { + return doc; } - // Check if we can skip running the package manager - // (resolved version already satisfies the new constraint) - const resolvedDep = marker.resolvedDependencies?.find( - rd => rd.name === recipe.packageName - ); - const skipInstall = resolvedDep !== undefined && - semver.satisfies(resolvedDep.version, recipe.newVersion); + const skipInstall = matchedDeps.every(md => { + const resolvedDep = marker.resolvedDependencies?.find(rd => rd.name === md.packageName); + return resolvedDep !== undefined && semver.satisfies(resolvedDep.version, recipe.newVersion); + }); - // Serialize npmrc configs from marker using requested scopes const configFiles: Record = {}; const npmrcContent = serializeNpmrcConfigs(marker.npmrcConfigs); if (npmrcContent) { @@ -236,8 +251,7 @@ export class UpgradeDependencyVersion extends ScanningRecipe { acc.projectsToUpdate.set(doc.sourcePath, { packageJsonPath: doc.sourcePath, originalPackageJson: await TreePrinters.print(doc), - dependencyScope: foundScope, - currentVersion, + matchedDependencies: matchedDeps, newVersion: recipe.newVersion, packageManager: pm, skipInstall, @@ -280,32 +294,31 @@ export class UpgradeDependencyVersion extends ScanningRecipe { return doc; // This package.json doesn't need updating } - // Run package manager install if needed, check for failure - // Skip if the resolved version already satisfies the new constraint const failureMessage = updateInfo.skipInstall ? undefined : await runInstallIfNeeded(sourcePath, acc, () => recipe.runPackageManagerInstall(acc, updateInfo, ctx) ); if (failureMessage) { + const names = updateInfo.matchedDependencies.map(d => d.packageName).join(', '); return markupWarn( doc, - `Failed to upgrade ${recipe.packageName} to ${recipe.newVersion}`, + `Failed to upgrade ${names} to ${recipe.newVersion}`, failureMessage ); } - // Update the dependency version in the JSON AST (preserves formatting) - const visitor = new UpdateVersionVisitor( - recipe.packageName, - updateInfo.newVersion, - updateInfo.dependencyScope - ); - const modifiedDoc = await visitor.visit(doc, undefined) as Json.Document; + let modifiedDoc = doc; + for (const md of updateInfo.matchedDependencies) { + const visitor = new UpdateVersionVisitor( + md.packageName, + updateInfo.newVersion, + md.dependencyScope + ); + modifiedDoc = await visitor.visit(modifiedDoc, undefined) as Json.Document; + } - // Update the NodeResolutionResult marker if (updateInfo.skipInstall) { - // Just update the versionConstraint in the marker - resolved version is unchanged return recipe.updateMarkerVersionConstraint(modifiedDoc, updateInfo); } return updateNodeResolutionMarker(modifiedDoc, updateInfo, acc); @@ -344,10 +357,9 @@ export class UpgradeDependencyVersion extends ScanningRecipe { updateInfo: ProjectUpdateInfo, _ctx: ExecutionContext ): Promise { - // Create modified package.json with the new version constraint const modifiedPackageJson = this.createModifiedPackageJson( updateInfo.originalPackageJson, - updateInfo.dependencyScope, + updateInfo.matchedDependencies, updateInfo.newVersion ); @@ -373,28 +385,22 @@ export class UpgradeDependencyVersion extends ScanningRecipe { storeInstallResult(result, acc, updateInfo, modifiedPackageJson); } - /** - * Creates a modified package.json with the updated dependency version. - * Used for the temp directory to validate the version exists. - */ private createModifiedPackageJson( originalContent: string, - scope: DependencyScope, + matchedDependencies: MatchedDependency[], newVersion: string ): string { const packageJson = JSON.parse(originalContent); - if (packageJson[scope] && packageJson[scope][this.packageName]) { - packageJson[scope][this.packageName] = newVersion; + for (const md of matchedDependencies) { + if (packageJson[md.dependencyScope]?.[md.packageName]) { + packageJson[md.dependencyScope][md.packageName] = newVersion; + } } return JSON.stringify(packageJson, null, 2); } - /** - * Updates just the versionConstraint in the marker for the target dependency. - * Used when skipInstall is true - the resolved version is unchanged. - */ private updateMarkerVersionConstraint( doc: Json.Document, updateInfo: ProjectUpdateInfo @@ -404,18 +410,19 @@ export class UpgradeDependencyVersion extends ScanningRecipe { return doc; } - // Update the versionConstraint for the target dependency - const deps = existingMarker[updateInfo.dependencyScope]; - const updatedDeps = deps?.map(dep => - dep.name === this.packageName - ? {...dep, versionConstraint: updateInfo.newVersion} - : dep - ); - - const newMarker = { - ...existingMarker, - [updateInfo.dependencyScope]: updatedDeps - }; + const matchedNames = new Set(updateInfo.matchedDependencies.map(md => md.packageName)); + let newMarker = {...existingMarker}; + for (const md of updateInfo.matchedDependencies) { + const deps = newMarker[md.dependencyScope]; + newMarker = { + ...newMarker, + [md.dependencyScope]: deps?.map(dep => + matchedNames.has(dep.name) + ? {...dep, versionConstraint: updateInfo.newVersion} + : dep + ) + }; + } return { ...doc, diff --git a/rewrite-javascript/rewrite/test/javascript/recipes/upgrade-dependency-version.test.ts b/rewrite-javascript/rewrite/test/javascript/recipes/upgrade-dependency-version.test.ts index f331bbe0000..60194fa94b5 100644 --- a/rewrite-javascript/rewrite/test/javascript/recipes/upgrade-dependency-version.test.ts +++ b/rewrite-javascript/rewrite/test/javascript/recipes/upgrade-dependency-version.test.ts @@ -521,6 +521,114 @@ describe("UpgradeDependencyVersion", () => { }, {unsafeCleanup: true}); }); + test("upgrades multiple scoped packages matching a pattern", async () => { + const spec = new RecipeSpec(); + spec.recipe = new UpgradeDependencyVersion({ + packagePattern: "@angular/*", + newVersion: "^19.0.0" + }); + + await withDir(async (repo) => { + await spec.rewriteRun( + npm( + repo.path, + typescript(`const x = 1;`), + packageJson(` + { + "name": "test-project", + "version": "1.0.0", + "dependencies": { + "@angular/core": "^18.0.0", + "@angular/common": "^18.0.0", + "rxjs": "^7.0.0" + } + } + `, ` + { + "name": "test-project", + "version": "1.0.0", + "dependencies": { + "@angular/core": "^19.0.0", + "@angular/common": "^19.0.0", + "rxjs": "^7.0.0" + } + } + `) + ) + ); + }, {unsafeCleanup: true}); + }); + + test("does not modify when pattern matches no packages", async () => { + const spec = new RecipeSpec(); + spec.recipe = new UpgradeDependencyVersion({ + packagePattern: "@vue/*", + newVersion: "^4.0.0" + }); + + await withDir(async (repo) => { + await spec.rewriteRun( + npm( + repo.path, + typescript(`const x = 1;`), + packageJson(` + { + "name": "test-project", + "version": "1.0.0", + "dependencies": { + "lodash": "^4.17.20" + } + } + `) + ) + ); + }, {unsafeCleanup: true}); + }); + + test("throws when neither packageName nor packagePattern is specified", () => { + const recipe = new UpgradeDependencyVersion({ + newVersion: "^2.0.0" + }); + expect(() => recipe.initialValue({} as any)).toThrow( + "Either packageName or packagePattern must be specified" + ); + }); + + describe("matchesPackage", () => { + + test("matches exact package name", () => { + const recipe = new UpgradeDependencyVersion({ + packageName: "lodash", + newVersion: "^5.0.0" + }); + expect(recipe.matchesPackage("lodash")).toBe(true); + expect(recipe.matchesPackage("underscore")).toBe(false); + }); + + test("matches glob pattern", () => { + const recipe = new UpgradeDependencyVersion({ + packagePattern: "@angular/*", + newVersion: "^19.0.0" + }); + expect(recipe.matchesPackage("@angular/core")).toBe(true); + expect(recipe.matchesPackage("@angular/common")).toBe(true); + expect(recipe.matchesPackage("@types/node")).toBe(false); + expect(recipe.matchesPackage("angular")).toBe(false); + }); + + test("matches when both packageName and packagePattern are set", () => { + const recipe = new UpgradeDependencyVersion({ + packageName: "rxjs", + packagePattern: "@angular/*", + newVersion: "^19.0.0" + }); + expect(recipe.matchesPackage("rxjs")).toBe(true); + expect(recipe.matchesPackage("@angular/core")).toBe(true); + expect(recipe.matchesPackage("lodash")).toBe(false); + }); + + }); + describe("shouldUpgrade semver comparison", () => { test("should not upgrade when versions are identical", () => {