/*
* Copyright 2025 the original author or authors.
*
* Licensed under the Moderne Source Available License (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://docs.moderne.io/licensing/moderne-source-available-license
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {Option, Recipe} from "../../recipe";
import {ExecutionContext} from "../../execution";
import {TreeVisitor} from "../../visitor";
import {getMemberKeyName, isMember, Json, JsonVisitor} from "../../json";
import {foundSearchResult} from "../../markers";
import {
allDependencyScopes,
Dependency,
DependencyScope,
findNodeResolutionResult,
NodeResolutionResult,
ResolvedDependency
} from "../node-resolution-result";
import * as semver from "semver";
import * as picomatch from "picomatch";
/** Dependency section names in package.json */
const DEPENDENCY_SECTIONS = new Set(allDependencyScopes);
/**
* Finds npm/Node.js dependencies declared in package.json.
* This recipe is commonly used as a precondition to limit the scope of other recipes
* to projects that use a specific dependency.
*
* The search result marker is placed on the specific dependency entry in package.json,
* allowing users to see exactly where the dependency is declared.
*
* When `onlyDirect` is false, this recipe also marks direct dependencies that
* transitively depend on the target package, helping answer "which of my dependencies
* brings in package X?".
*/
export interface FindDependencyOptions {
packageName: string;
version?: string;
onlyDirect?: boolean;
}
export class FindDependency extends Recipe {
readonly name = "org.openrewrite.javascript.dependencies.find-dependency";
readonly displayName = "Find Node.js dependency";
readonly description = "Finds dependencies in a project's `package.json`. " +
"Can find both direct dependencies and dependencies that transitively include the target package. " +
"This recipe is commonly used as a precondition for other recipes.";
@Option({
displayName: "Package name",
description: "The name of the npm package to find. Supports glob patterns.",
example: "lodash"
})
packageName!: string;
@Option({
displayName: "Version",
description: "An exact version number or semver selector used to select the version number. " +
"Leave empty to match any version.",
example: "^18.0.0",
required: false
})
version?: string;
@Option({
displayName: "Only direct dependencies",
description: "If true (default), only matches dependencies that directly match the package name. " +
"If false, also marks direct dependencies that have the target package as a transitive dependency.",
example: "true",
required: false
})
onlyDirect?: boolean;
constructor(options: FindDependencyOptions) {
super(options);
// Handle string values from RPC serialization (e.g., "false" instead of false)
// and default to true if not specified
this.onlyDirect = !(this.onlyDirect === false || (this.onlyDirect as any === "false"));
}
override instanceName(): string {
return `${this.displayName} \`${this.packageName}${this.version ? '@' + this.version : ''}\``;
}
async editor(): Promise> {
const packageName = this.packageName;
const version = this.version;
const onlyDirect = this.onlyDirect;
// Create a picomatch matcher for the package name pattern
// For patterns without '/', use { contains: true } so that '*jest*' matches '@types/jest'
// (by default, '*' doesn't match '/' in glob patterns, but for package names this is more intuitive)
const matchOptions = packageName.includes('/') ? {} : { contains: true };
const matcher: picomatch.Matcher = picomatch.default
? picomatch.default(packageName, matchOptions)
: (picomatch as any)(packageName, matchOptions);
return new class extends JsonVisitor {
private resolution: NodeResolutionResult | undefined;
private isPackageJson: boolean = false;
protected override async visitDocument(document: Json.Document, ctx: ExecutionContext): Promise {
// Only process package.json files, not package-lock.json or other JSON files
const sourcePath = document.sourcePath;
this.isPackageJson = sourcePath.endsWith('package.json');
if (this.isPackageJson) {
this.resolution = findNodeResolutionResult(document);
}
return this.isPackageJson && this.resolution ? super.visitDocument(document, ctx) : document;
}
protected override async visitMember(member: Json.Member, ctx: ExecutionContext): Promise {
// Check if we're inside a dependency section
const parentSection = this.getParentDependencySection();
if (!parentSection) {
return super.visitMember(member, ctx);
}
// Get the package name from the member key
const depName = getMemberKeyName(member);
if (!depName) {
return super.visitMember(member, ctx);
}
// Find the dependency in the resolution result
const dep = this.findDependencyByName(depName, parentSection);
if (!dep) {
return super.visitMember(member, ctx);
}
// Check if this dependency matches directly
if (matcher(depName) && versionMatches(dep, version)) {
return this.markDependency(member, ctx);
}
// If not only direct, check if this dependency has the target as a transitive dependency
if (!onlyDirect && dep.resolved) {
if (hasTransitiveDependency(dep.resolved, matcher, version, new Set())) {
return this.markDependency(member, ctx);
}
}
return super.visitMember(member, ctx);
}
/**
* Marks the dependency key with a search result marker.
*/
private async markDependency(member: Json.Member, ctx: ExecutionContext): Promise {
const visitedMember = await super.visitMember(member, ctx) as Json.Member;
const markedKey = foundSearchResult(visitedMember.key.element);
return {
...visitedMember,
key: {
...visitedMember.key,
element: markedKey
}
} as Json.Member;
}
/**
* Checks if the current member's parent is a dependency section object.
* Returns the section name if so, undefined otherwise.
*/
private getParentDependencySection(): DependencyScope | undefined {
// Walk up the cursor to find the parent member that contains this dependency
// Structure: Document > Object > Member("dependencies") > Object > Member("lodash")
let cursor = this.cursor.parent;
while (cursor) {
const tree = cursor.value;
if (tree && typeof tree === 'object' && 'kind' in tree && isMember(tree as Json)) {
const memberKey = getMemberKeyName(tree as Json.Member);
if (memberKey && DEPENDENCY_SECTIONS.has(memberKey as DependencyScope)) {
return memberKey as DependencyScope;
}
}
cursor = cursor.parent;
}
return undefined;
}
/**
* Finds a dependency by name in the appropriate section of the resolution result.
*/
private findDependencyByName(name: string, section: DependencyScope): Dependency | undefined {
if (!this.resolution) return undefined;
const deps = this.resolution[section];
return deps?.find(d => d.name === name);
}
};
}
}
/**
* Recursively checks if a resolved dependency has the target package as a transitive dependency.
*/
function hasTransitiveDependency(
resolved: ResolvedDependency,
matcher: picomatch.Matcher,
version: string | undefined,
visited: Set
): boolean {
// Avoid cycles
const key = `${resolved.name}@${resolved.version}`;
if (visited.has(key)) {
return false;
}
visited.add(key);
// Check all dependency types
const allDeps = [
...(resolved.dependencies || []),
...(resolved.devDependencies || []),
...(resolved.peerDependencies || []),
...(resolved.optionalDependencies || [])
];
for (const dep of allDeps) {
// Check if this dependency matches the target
if (matcher(dep.name) && versionMatches(dep, version)) {
return true;
}
// Recursively check transitive dependencies
if (dep.resolved && hasTransitiveDependency(dep.resolved, matcher, version, visited)) {
return true;
}
}
return false;
}
function versionMatches(dep: Dependency, version: string | undefined): boolean {
if (!version) {
return true;
}
const resolved = dep.resolved;
if (!resolved) {
// If no resolved version available, we can't validate the version
return false;
}
return versionMatchesResolved(resolved, version);
}
function versionMatchesResolved(resolved: ResolvedDependency, version: string | undefined): boolean {
if (!version) {
return true;
}
const actualVersion = resolved.version;
// Use semver.satisfies to check if the actual version matches the constraint
return semver.satisfies(actualVersion, version);
}