Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<string, string>;
}

Expand All @@ -92,14 +84,23 @@ interface Accumulator extends DependencyRecipeAccumulator<ProjectUpdateInfo> {
export class UpgradeDependencyVersion extends ScanningRecipe<Accumulator> {
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",
Expand All @@ -108,7 +109,31 @@ export class UpgradeDependencyVersion extends ScanningRecipe<Accumulator> {
})
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<ProjectUpdateInfo>(),
originalLockFiles: new Map()
Expand Down Expand Up @@ -193,40 +218,30 @@ export class UpgradeDependencyVersion extends ScanningRecipe<Accumulator> {

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<string, string> = {};
const npmrcContent = serializeNpmrcConfigs(marker.npmrcConfigs);
if (npmrcContent) {
Expand All @@ -236,8 +251,7 @@ export class UpgradeDependencyVersion extends ScanningRecipe<Accumulator> {
acc.projectsToUpdate.set(doc.sourcePath, {
packageJsonPath: doc.sourcePath,
originalPackageJson: await TreePrinters.print(doc),
dependencyScope: foundScope,
currentVersion,
matchedDependencies: matchedDeps,
newVersion: recipe.newVersion,
packageManager: pm,
skipInstall,
Expand Down Expand Up @@ -280,32 +294,31 @@ export class UpgradeDependencyVersion extends ScanningRecipe<Accumulator> {
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);
Expand Down Expand Up @@ -344,10 +357,9 @@ export class UpgradeDependencyVersion extends ScanningRecipe<Accumulator> {
updateInfo: ProjectUpdateInfo,
_ctx: ExecutionContext
): Promise<void> {
// Create modified package.json with the new version constraint
const modifiedPackageJson = this.createModifiedPackageJson(
updateInfo.originalPackageJson,
updateInfo.dependencyScope,
updateInfo.matchedDependencies,
updateInfo.newVersion
);

Expand All @@ -373,28 +385,22 @@ export class UpgradeDependencyVersion extends ScanningRecipe<Accumulator> {
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
Expand All @@ -404,18 +410,19 @@ export class UpgradeDependencyVersion extends ScanningRecipe<Accumulator> {
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,
Expand Down
Loading