/*
* 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 {findMarker, Marker, Markers} from "../markers";
import {randomId, UUID} from "../uuid";
import {asRef} from "../reference";
import {RpcCodecs, RpcReceiveQueue, RpcSendQueue} from "../rpc";
import {castDraft} from "mutative";
import {updateIfChanged} from "../util";
import * as semver from "semver";
import * as fsp from "fs/promises";
import * as path from "path";
import {homedir} from "os";
export const NodeResolutionResultKind = "org.openrewrite.javascript.marker.NodeResolutionResult" as const;
export const DependencyKind = "org.openrewrite.javascript.marker.NodeResolutionResult$Dependency" as const;
export const ResolvedDependencyKind = "org.openrewrite.javascript.marker.NodeResolutionResult$ResolvedDependency" as const;
/**
* Parsed package.json content structure.
*/
export interface PackageJsonContent {
readonly name?: string;
readonly version?: string;
readonly description?: string;
readonly dependencies?: Record;
readonly devDependencies?: Record;
readonly peerDependencies?: Record;
readonly optionalDependencies?: Record;
readonly bundledDependencies?: string[];
readonly bundleDependencies?: string[]; // Legacy alias
readonly engines?: Record;
}
/**
* Package entry in a package-lock.json packages map.
*/
export interface PackageLockEntry {
readonly version?: string;
readonly resolved?: string;
readonly integrity?: string;
readonly license?: string | string[] | { type?: string; url?: string }; // Can be legacy formats
readonly dependencies?: Record;
readonly devDependencies?: Record;
readonly peerDependencies?: Record;
readonly optionalDependencies?: Record;
readonly engines?: Record | string[]; // Can be legacy array format
}
/**
* Parsed package-lock.json content structure (npm lockfile v3 format).
*/
export interface PackageLockContent {
readonly name?: string;
readonly version?: string;
readonly lockfileVersion?: number;
readonly packages?: Record;
}
/**
* Represents the package manager used by a Node.js project.
*/
export const enum PackageManager {
Npm = 'Npm',
YarnClassic = 'YarnClassic',
YarnBerry = 'YarnBerry',
Pnpm = 'Pnpm',
Bun = 'Bun',
}
/**
* Represents the scope/source of an npmrc configuration.
* Listed from lowest to highest priority.
*/
export const enum NpmrcScope {
Global = 'Global', // $PREFIX/etc/npmrc
User = 'User', // $HOME/.npmrc
Project = 'Project', // .npmrc in project root
}
/**
* Represents a dependency scope in package.json that uses object structure {name: version}.
* Note: `bundledDependencies` is excluded because it's a string[] of package names, not a version map.
*/
export type DependencyScope = 'dependencies' | 'devDependencies' | 'peerDependencies' | 'optionalDependencies';
/**
* All dependency scopes in package.json that use object structure {name: version}.
*/
export const allDependencyScopes: readonly DependencyScope[] = [
'dependencies',
'devDependencies',
'peerDependencies',
'optionalDependencies'
] as const;
export const NpmrcKind = "org.openrewrite.javascript.marker.NodeResolutionResult$Npmrc" as const;
/**
* Represents npm configuration from a specific scope.
* Multiple Npmrc objects can be collected (one per scope) to allow
* recipes to merge configurations or modify specific scopes.
*/
export interface Npmrc {
readonly kind: typeof NpmrcKind;
readonly scope: NpmrcScope;
readonly properties: Record;
}
/**
* Represents a dependency request as declared in package.json.
* This is what a package asks for (name + version constraint).
*
* When the same name+versionConstraint appears multiple times, the same
* Dependency instance is reused. This enables reference deduplication
* during RPC serialization via asRef().
*/
export interface Dependency {
readonly kind: typeof DependencyKind;
readonly name: string; // Package name (e.g., "react")
readonly versionConstraint: string; // Version constraint (e.g., "^18.2.0")
readonly resolved?: ResolvedDependency; // The resolved version of this dependency
}
/**
* Represents a resolved dependency from package-lock.json.
* This is what was actually installed (name + resolved version + its own dependencies).
*
* Each ResolvedDependency's dependency arrays contain Dependency objects (requests),
* which can be looked up in NodeResolutionResult.resolvedDependencies to find their resolved versions.
*/
export interface ResolvedDependency {
readonly kind: typeof ResolvedDependencyKind;
readonly name: string; // Package name (e.g., "react")
readonly version: string; // Actual resolved version (e.g., "18.3.1")
// This package's own dependency requests
readonly dependencies?: Dependency[];
readonly devDependencies?: Dependency[];
readonly peerDependencies?: Dependency[];
readonly optionalDependencies?: Dependency[];
// Node/npm version requirements for this package
readonly engines?: Record;
// SPDX license identifier (e.g., "MIT", "Apache-2.0")
readonly license?: string;
}
/**
* Contains metadata about a Node.js project, parsed from package.json and package-lock.json.
* Attached as a marker to JS.CompilationUnit to provide dependency context for recipes.
*
* Similar to MavenResolutionResult marker, this allows recipes to:
* - Query project dependencies
* - Check if specific packages are in use
* - Modify dependencies programmatically
* - Understand the project structure
*
* The model separates requests (Dependency) from resolutions (ResolvedDependency):
* - The dependency arrays contain Dependency objects (what was requested)
* - The resolvedDependencies list contains what was actually installed
*/
export interface NodeResolutionResult extends Marker {
readonly kind: typeof NodeResolutionResultKind;
readonly id: UUID;
// Project metadata from package.json
readonly name?: string; // Project name
readonly version?: string; // Project version
readonly description?: string; // Project description
readonly path: string; // Path to the package.json file
// Paths to workspace package.json files (only populated on workspace root)
readonly workspacePackagePaths?: string[];
// Dependency requests organized by scope (from package.json)
readonly dependencies: Dependency[]; // Regular dependencies
readonly devDependencies: Dependency[]; // Development dependencies
readonly peerDependencies: Dependency[]; // Peer dependencies
readonly optionalDependencies: Dependency[]; // Optional dependencies
readonly bundledDependencies: Dependency[]; // Bundled dependencies
// Resolved dependencies from lock file - what was actually installed
// Use getAllResolvedVersions() to look up by name (handles multiple versions)
readonly resolvedDependencies: ResolvedDependency[];
// The package manager used by the project (npm, yarn, pnpm, etc.)
readonly packageManager?: PackageManager;
// Node/npm version requirements
readonly engines?: Record;
// npm configuration from various scopes (global, user, project, env)
readonly npmrcConfigs?: Npmrc[];
}
/**
* Creates a NodeResolutionResult marker from a package.json file.
* Should be called during parsing to attach to JS.CompilationUnit.
*
* All Dependency instances are wrapped with asRef() to enable
* reference deduplication during RPC serialization.
*
* @param path Path to the package.json file
* @param packageJsonContent Parsed package.json content
* @param packageLockContent Optional parsed package-lock.json content for resolution info
* @param workspacePackagePaths Optional resolved paths to workspace package.json files (only for workspace root)
* @param packageManager Optional package manager that was detected from lock file
* @param npmrcConfigs Optional npm configuration from various scopes
*/
export function createNodeResolutionResultMarker(
path: string,
packageJsonContent: PackageJsonContent,
packageLockContent?: PackageLockContent,
workspacePackagePaths?: string[],
packageManager?: PackageManager,
npmrcConfigs?: Npmrc[]
): NodeResolutionResult {
// Cache for deduplicating resolved dependencies with the same name+version.
const resolvedDependencyCache = new Map();
// Index from package name to all resolved versions (for O(1) semver fallback lookup)
const nameToResolved = new Map();
// Map from lock file path to ResolvedDependency for path-based lookups.
// e.g., "node_modules/is-odd" -> ResolvedDependency for is-odd@3.0.1
const pathToResolved = new Map();
// Cache for deduplicating dependencies with the same name+versionConstraint+contextPath.
const dependencyCache = new Map();
/**
* Coerces the engines field to the Map shape Java's
* NodeResolutionResult.ResolvedDependency expects. The npm registry
* accepts and serves several shapes that don't fit that type; sending
* any of them across RPC throws ClassCastException, which (because
* pendingData survives mid-stream failures) cascades the receive queue
* out of sync for every subsequent file in the build.
*
* Shapes seen in the wild and what we do with them:
* - object: pass through.
* - ["node >=0.6.0"]: split on the first space → {node: ">=0.6.0"}.
* - ["node", "rhino"]: bare names with no version. Preserve the
* engine list as {node: "*", rhino: "*"}.
* - ">=0.10.40": bare semver with no engine name (qs@5.2.1 ships this
* verbatim from its published package.json). npm itself drops it —
* npm-install-checks reads eng.node/eng.npm off the string, both
* undefined, so the constraint is silently ignored. Match that.
*/
function normalizeEngines(engines?: unknown): Record | undefined {
if (!engines) return undefined;
if (Array.isArray(engines)) {
const result: Record = {};
for (const entry of engines) {
if (typeof entry !== 'string') continue;
const spaceIdx = entry.indexOf(' ');
if (spaceIdx > 0) {
result[entry.substring(0, spaceIdx)] = entry.substring(spaceIdx + 1);
} else {
result[entry] = "*";
}
}
return Object.keys(result).length > 0 ? result : undefined;
}
if (typeof engines !== 'object') return undefined;
return engines as Record;
}
/**
* Normalizes the license field from package-lock.json.
* Older packages may have license in legacy formats:
* - Array: ["MIT", "Apache2"] -> "(MIT OR Apache2)"
* - Object: { type: "MIT", url: "..." } -> "MIT"
*/
function normalizeLicense(license?: unknown): string | undefined {
if (!license) return undefined;
if (Array.isArray(license)) {
// Convert array format to SPDX OR expression
// e.g., ["MIT", "Apache2"] -> "(MIT OR Apache2)"
return license.length > 0 ? `(${license.join(' OR ')})` : undefined;
}
if (typeof license === 'object') {
// Extract type from object format
// e.g., { type: "MIT", url: "..." } -> "MIT"
const type = (license as { type?: unknown }).type;
return typeof type === 'string' ? type : undefined;
}
return typeof license === 'string' ? license : undefined;
}
/**
* Extracts package name and optionally version from a package-lock.json path.
* e.g., "node_modules/@babel/core" -> { name: "@babel/core" }
* e.g., "node_modules/foo/node_modules/bar" -> { name: "bar" }
* e.g., "node_modules/is-odd@3.0.1" -> { name: "is-odd", version: "3.0.1" }
*/
function extractPackageInfo(pkgPath: string): { name: string; version?: string } {
// For nested packages, we want the last package name
const nodeModulesIndex = pkgPath.lastIndexOf('node_modules/');
if (nodeModulesIndex === -1) return {name: pkgPath};
let nameWithVersion = pkgPath.slice(nodeModulesIndex + 'node_modules/'.length);
// Check if the path has a version suffix (e.g., "is-odd@3.0.1")
// Handle scoped packages (@scope/name@version) correctly
const atIndex = nameWithVersion.lastIndexOf('@');
if (atIndex > 0 && !nameWithVersion.substring(0, atIndex).includes('/')) {
// Not a scoped package, the @ is a version separator
return {
name: nameWithVersion.substring(0, atIndex),
version: nameWithVersion.substring(atIndex + 1)
};
} else if (atIndex > 0) {
// Could be scoped package with version: @scope/name@version
const parts = nameWithVersion.split('@');
if (parts.length === 3 && parts[0] === '') {
// @scope/name@version -> ["", "scope/name", "version"]
return {
name: `@${parts[1]}`,
version: parts[2]
};
}
}
return {name: nameWithVersion};
}
/**
* Creates or retrieves a ResolvedDependency from the cache.
* This ensures the same resolved package is reused across the dependency tree.
* Also maintains the nameToResolved index for O(1) name lookups.
*/
function getOrCreateResolvedDependency(
name: string,
version: string,
pkgEntry?: PackageLockEntry
): ResolvedDependency {
const key = `${name}@${version}`;
let resolved = resolvedDependencyCache.get(key);
if (!resolved) {
// Create a placeholder first - dependencies will be populated later
resolved = asRef({
kind: ResolvedDependencyKind,
name,
version,
dependencies: undefined,
devDependencies: undefined,
peerDependencies: undefined,
optionalDependencies: undefined,
engines: normalizeEngines(pkgEntry?.engines),
license: normalizeLicense(pkgEntry?.license),
}) as ResolvedDependency;
resolvedDependencyCache.set(key, resolved);
// Maintain name index for O(1) lookup during semver fallback
const existing = nameToResolved.get(name);
if (existing) {
existing.push(resolved);
} else {
nameToResolved.set(name, [resolved]);
}
}
return resolved;
}
/**
* Resolves a dependency name from a given context path using Node.js-style resolution.
* Looks for the package in nested node_modules first, then walks up to parent directories.
* Falls back to semver matching when path-based resolution fails (e.g., for yarn/pnpm).
*
* @param name Package name to resolve
* @param versionConstraint Version constraint (e.g., "^3.0.1") for semver fallback
* @param contextPath The path of the parent package (e.g., "node_modules/is-even")
* Use "" for root-level dependencies
*/
function resolveFromContext(
name: string,
versionConstraint: string,
contextPath: string
): ResolvedDependency | undefined {
// Start from the context path and walk up looking for the package
let currentPath = contextPath;
while (true) {
// Try to find the package in node_modules at this level
const candidatePath = currentPath
? `${currentPath}/node_modules/${name}`
: `node_modules/${name}`;
const resolved = pathToResolved.get(candidatePath);
if (resolved) {
return resolved;
}
// Walk up to parent directory
if (!currentPath) {
break; // Already at root
}
// Remove the last /node_modules/pkg segment to go up one level
const lastNodeModules = currentPath.lastIndexOf('/node_modules/');
if (lastNodeModules === -1) {
currentPath = ''; // Try root level next
} else {
currentPath = currentPath.substring(0, lastNodeModules);
}
}
// Fallback: use semver matching to find a version that satisfies the constraint
// This is needed for yarn/pnpm which don't encode nesting in their lock files
const candidates = nameToResolved.get(name);
if (!candidates || candidates.length === 0) {
return undefined;
}
if (candidates.length === 1) {
return candidates[0];
}
// Multiple versions - use semver to find the best match
// First try to find one that satisfies the constraint
for (const candidate of candidates) {
try {
if (semver.satisfies(candidate.version, versionConstraint)) {
return candidate;
}
} catch {
// Invalid semver, skip this candidate for matching
}
}
// If no exact match, return the highest version as fallback (O(n) linear scan)
let maxCandidate = candidates[0];
for (let i = 1; i < candidates.length; i++) {
try {
if (semver.compare(candidates[i].version, maxCandidate.version) > 0) {
maxCandidate = candidates[i];
}
} catch {
// Invalid semver, skip comparison
}
}
return maxCandidate;
}
/**
* Creates or retrieves a Dependency from the cache.
* Links to resolved dependency using path-based Node.js-style resolution.
*
* @param name Package name
* @param versionConstraint Version constraint from package.json
* @param contextPath The path context for resolution (parent package path)
*/
function getOrCreateDependency(
name: string,
versionConstraint: string,
contextPath: string
): Dependency {
// Resolve first to determine the actual resolved version
const resolved = resolveFromContext(name, versionConstraint, contextPath);
// Key by name, constraint, and resolved version (not context path).
// This allows sharing Dependency objects when different contexts resolve to the same version.
const resolvedKey = resolved ? `${resolved.name}@${resolved.version}` : 'unresolved';
const key = `${name}@${versionConstraint}@${resolvedKey}`;
let dep = dependencyCache.get(key);
if (!dep) {
dep = asRef({
kind: DependencyKind,
name,
versionConstraint,
resolved,
}) as Dependency;
dependencyCache.set(key, dep);
}
return dep;
}
/**
* Parses dependencies from a Record, using the given context path for resolution.
*/
function parseDependencies(
deps: Record | undefined,
contextPath: string
): Dependency[] {
if (!deps) return [];
return Object.entries(deps).map(([name, versionConstraint]) =>
getOrCreateDependency(name, versionConstraint, contextPath)
);
}
/**
* Parses bundled dependencies (array of names with no version constraint).
*/
function parseBundledDependencies(
deps: string[] | undefined,
contextPath: string
): Dependency[] {
if (!deps) return [];
return deps.map(name => getOrCreateDependency(name, '*', contextPath));
}
/**
* Parses package-lock.json to build a list of resolved dependencies.
* Two-pass approach:
* 1. First pass: Create all ResolvedDependency objects and build path->resolved map
* 2. Second pass: Populate dependencies using path-based resolution
*/
function parseResolutions(
lockContent: PackageLockContent
): ResolvedDependency[] {
if (!lockContent.packages) return [];
const packages = lockContent.packages;
// First pass: Create all ResolvedDependency placeholders and build path map
const packageInfos: Array<{ path: string; name: string; version: string; entry: PackageLockEntry }> = [];
for (const [pkgPath, pkgEntry] of Object.entries(packages)) {
// Skip the root package (empty string key)
if (pkgPath === '') continue;
const pkgInfo = extractPackageInfo(pkgPath);
const name = pkgInfo.name;
// Use version from path if available, otherwise from entry
const version = pkgInfo.version || pkgEntry.version;
if (name && version) {
const resolved = getOrCreateResolvedDependency(name, version, pkgEntry);
pathToResolved.set(pkgPath, resolved);
packageInfos.push({path: pkgPath, name, version, entry: pkgEntry});
}
}
// Second pass: Populate dependencies using path-based resolution.
// Note: Using castDraft here is safe because all objects are created within this
// parsing context and haven't been returned to callers yet. The objects in
// resolvedDependencyCache are plain JS objects marked with asRef() for RPC
// reference deduplication.
for (const {path: pkgPath, name, version, entry} of packageInfos) {
const key = `${name}@${version}`;
const resolved = resolvedDependencyCache.get(key);
if (resolved) {
const mutableResolved = castDraft(resolved);
if (entry.dependencies) {
mutableResolved.dependencies = parseDependencies(entry.dependencies, pkgPath);
}
if (entry.devDependencies) {
mutableResolved.devDependencies = parseDependencies(entry.devDependencies, pkgPath);
}
if (entry.peerDependencies) {
mutableResolved.peerDependencies = parseDependencies(entry.peerDependencies, pkgPath);
}
if (entry.optionalDependencies) {
mutableResolved.optionalDependencies = parseDependencies(entry.optionalDependencies, pkgPath);
}
}
}
// Return all unique resolved dependencies
return Array.from(resolvedDependencyCache.values());
}
// Parse resolved dependencies first (before dependencies) so we can link them
const resolvedDependencies = packageLockContent ? parseResolutions(packageLockContent) : [];
// Now parse dependencies from package.json - they resolve from root context ("")
const dependencies = parseDependencies(packageJsonContent.dependencies, '');
const devDependencies = parseDependencies(packageJsonContent.devDependencies, '');
const peerDependencies = parseDependencies(packageJsonContent.peerDependencies, '');
const optionalDependencies = parseDependencies(packageJsonContent.optionalDependencies, '');
const bundledDependencies = parseBundledDependencies(
packageJsonContent.bundledDependencies || packageJsonContent.bundleDependencies,
''
);
return {
kind: NodeResolutionResultKind,
id: randomId(),
name: packageJsonContent.name,
version: packageJsonContent.version,
description: packageJsonContent.description,
path,
workspacePackagePaths,
dependencies,
devDependencies,
peerDependencies,
optionalDependencies,
bundledDependencies,
resolvedDependencies,
packageManager,
engines: normalizeEngines(packageJsonContent.engines),
npmrcConfigs,
} as NodeResolutionResult;
}
/**
* Helper function to find a NodeResolutionResult marker on a compilation unit.
*/
export function findNodeResolutionResult(cu: { markers: Markers }): NodeResolutionResult | undefined {
return findMarker(cu, NodeResolutionResultKind);
}
/**
* Parses an .npmrc file content into a key-value map.
*
* .npmrc format:
* - Lines are key=value pairs
* - Lines starting with # or ; are comments
* - Empty lines are ignored
* - Values can contain ${VAR} or ${VAR:-default} for env variable substitution
*/
function parseNpmrc(content: string): Record {
const properties: Record = {};
for (const line of content.split('\n')) {
const trimmed = line.trim();
// Skip comments and empty lines
if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith(';')) {
continue;
}
// Parse key=value
const eqIndex = trimmed.indexOf('=');
if (eqIndex === -1) continue;
const key = trimmed.substring(0, eqIndex).trim();
const value = trimmed.substring(eqIndex + 1).trim();
if (key) {
properties[key] = value;
}
}
return properties;
}
/**
* Serializes Npmrc configurations to .npmrc file format.
*
* Merges properties from multiple scopes in priority order (Global -> User -> Project),
* so higher-priority scopes override lower-priority ones.
*
* @param configs Array of Npmrc configurations from the marker
* @param scopes Which scopes to include (default: ['Project'])
* @returns Serialized .npmrc content, or undefined if no matching configs
*/
export function serializeNpmrcConfigs(
configs: Npmrc[] | undefined,
scopes: NpmrcScope[] = [NpmrcScope.Project]
): string | undefined {
if (!configs || configs.length === 0 || scopes.length === 0) {
return undefined;
}
// Define priority order (lowest to highest)
const scopePriority: NpmrcScope[] = [NpmrcScope.Global, NpmrcScope.User, NpmrcScope.Project];
// Filter and sort configs by priority
const filteredConfigs = configs
.filter(c => scopes.includes(c.scope))
.sort((a, b) => scopePriority.indexOf(a.scope) - scopePriority.indexOf(b.scope));
if (filteredConfigs.length === 0) {
return undefined;
}
// Merge properties in priority order (later entries override earlier)
const mergedProperties: Record = {};
for (const config of filteredConfigs) {
Object.assign(mergedProperties, config.properties);
}
if (Object.keys(mergedProperties).length === 0) {
return undefined;
}
// Serialize to .npmrc format
const lines: string[] = [];
for (const [key, value] of Object.entries(mergedProperties)) {
lines.push(`${key}=${value}`);
}
return lines.join('\n') + '\n';
}
/**
* Helper to check if a file exists asynchronously.
*/
async function fileExists(filePath: string): Promise {
try {
await fsp.access(filePath);
return true;
} catch {
return false;
}
}
/**
* Reads .npmrc configurations from all scope levels.
* Returns an array of Npmrc objects, one for each scope that has configuration.
*
* Scopes (from lowest to highest priority):
* - Global: $PREFIX/etc/npmrc (npm's installation directory)
* - User: $HOME/.npmrc (user's home directory)
* - Project: .npmrc in project root (sibling of package.json)
* - Env: npm_config_* environment variables
*
* @param projectDir The project directory containing package.json
* @returns Promise resolving to array of Npmrc objects for each scope with configuration
*/
export async function readNpmrcConfigs(projectDir: string): Promise {
const configs: Npmrc[] = [];
// 1. Global config: $PREFIX/etc/npmrc
// Try to get npm prefix from npm itself, fall back to common locations
const globalNpmrcPaths = [
// Try NPM_CONFIG_GLOBALCONFIG env var first
process.env.NPM_CONFIG_GLOBALCONFIG,
// Common global locations
'/usr/local/etc/npmrc',
'/etc/npmrc',
].filter(Boolean) as string[];
// Also try to detect from npm prefix if node is installed
const nodeDir = process.execPath ? path.dirname(path.dirname(process.execPath)) : undefined;
if (nodeDir) {
globalNpmrcPaths.unshift(path.join(nodeDir, 'etc', 'npmrc'));
}
for (const globalPath of globalNpmrcPaths) {
if (await fileExists(globalPath)) {
try {
const content = await fsp.readFile(globalPath, 'utf-8');
const properties = parseNpmrc(content);
if (Object.keys(properties).length > 0) {
configs.push({
kind: NpmrcKind,
scope: NpmrcScope.Global,
properties
});
break; // Only use the first found global config
}
} catch {
// Ignore read errors
}
}
}
// 2. User config: $HOME/.npmrc
const userNpmrcPath = process.env.NPM_CONFIG_USERCONFIG || path.join(homedir(), '.npmrc');
if (await fileExists(userNpmrcPath)) {
try {
const content = await fsp.readFile(userNpmrcPath, 'utf-8');
const properties = parseNpmrc(content);
if (Object.keys(properties).length > 0) {
configs.push({
kind: NpmrcKind,
scope: NpmrcScope.User,
properties
});
}
} catch {
// Ignore read errors
}
}
// 3. Project config: .npmrc in project root
const projectNpmrcPath = path.join(projectDir, '.npmrc');
if (await fileExists(projectNpmrcPath)) {
try {
const content = await fsp.readFile(projectNpmrcPath, 'utf-8');
const properties = parseNpmrc(content);
if (Object.keys(properties).length > 0) {
configs.push({
kind: NpmrcKind,
scope: NpmrcScope.Project,
properties
});
}
} catch {
// Ignore read errors
}
}
// Note: We intentionally don't capture npm_config_* environment variables.
// While users can set config via env vars, npm also automatically injects
// many npm_config_* vars when running child processes, and there's no way
// to distinguish user-set vars from npm-injected ones at runtime.
return configs;
}
/**
* Helper functions for querying dependencies
*/
export namespace NodeResolutionResultQueries {
/**
* Get all dependency requests from all scopes.
*/
export function getAllDependencies(project: NodeResolutionResult): Dependency[] {
return [
...project.dependencies,
...project.devDependencies,
...project.peerDependencies,
...project.optionalDependencies,
...project.bundledDependencies
];
}
/**
* Check if project has a specific dependency request, optionally filtered by scope.
*/
export function hasDependency(
project: NodeResolutionResult,
packageName: string,
scope?: 'dependencies' | 'devDependencies' | 'peerDependencies' | 'optionalDependencies' | 'bundledDependencies'
): boolean {
const deps = scope
? project[scope]
: getAllDependencies(project);
return deps.some(dep => dep.name === packageName);
}
/**
* Find a specific dependency request by name across all scopes.
*/
export function findDependency(
project: NodeResolutionResult,
packageName: string
): Dependency | undefined {
return getAllDependencies(project).find(dep => dep.name === packageName);
}
/**
* Get all dependency requests matching a predicate.
*/
export function findDependencies(
project: NodeResolutionResult,
predicate: (dep: Dependency) => boolean
): Dependency[] {
return getAllDependencies(project).filter(predicate);
}
/**
* Get all resolved dependencies with a specific name (handles multiple versions).
* Returns an empty array if no versions are found.
*
* For navigation, prefer using the Dependency.resolved property:
* @example
* const express = project.dependencies.find(d => d.name === 'express')?.resolved;
* const accepts = express?.dependencies?.find(d => d.name === 'accepts')?.resolved;
*/
export function getAllResolvedVersions(
project: NodeResolutionResult,
packageName: string
): ResolvedDependency[] {
return project.resolvedDependencies.filter(r => r.name === packageName);
}
}
/**
* Register RPC codec for Npmrc.
*/
RpcCodecs.registerCodec(NpmrcKind, {
async rpcReceive(before: Npmrc, q: RpcReceiveQueue): Promise {
return updateIfChanged(before, {
scope: await q.receive(before.scope),
properties: await q.receive(before.properties),
});
},
async rpcSend(after: Npmrc, q: RpcSendQueue): Promise {
await q.getAndSend(after, a => a.scope);
await q.getAndSend(after, a => a.properties);
}
});
/**
* Register RPC codec for Dependency.
*/
RpcCodecs.registerCodec(DependencyKind, {
async rpcReceive(before: Dependency, q: RpcReceiveQueue): Promise {
// Populated in place: the receive queue registered this instance for the ref before calling
// the codec, so a back-reference closing a cycle resolves to the finished object.
const dep = castDraft(before);
dep.name = await q.receive(before.name);
dep.versionConstraint = await q.receive(before.versionConstraint);
dep.resolved = await q.receive(before.resolved);
return before;
},
async rpcSend(after: Dependency, q: RpcSendQueue): Promise {
await q.getAndSend(after, a => a.name);
await q.getAndSend(after, a => a.versionConstraint);
await q.getAndSend(after, a => asRef(a.resolved));
}
});
/**
* Register RPC codec for ResolvedDependency.
*/
RpcCodecs.registerCodec(ResolvedDependencyKind, {
async rpcReceive(before: ResolvedDependency, q: RpcReceiveQueue): Promise {
const resolved = castDraft(before);
resolved.name = await q.receive(before.name);
resolved.version = await q.receive(before.version);
resolved.dependencies = (await q.receiveList(before.dependencies)) || undefined;
resolved.devDependencies = (await q.receiveList(before.devDependencies)) || undefined;
resolved.peerDependencies = (await q.receiveList(before.peerDependencies)) || undefined;
resolved.optionalDependencies = (await q.receiveList(before.optionalDependencies)) || undefined;
resolved.engines = await q.receive(before.engines);
resolved.license = await q.receive(before.license);
return before;
},
async rpcSend(after: ResolvedDependency, q: RpcSendQueue): Promise {
await q.getAndSend(after, a => a.name);
await q.getAndSend(after, a => a.version);
await q.getAndSendList(after, a => (a.dependencies || []).map(d => asRef(d)),
dep => `${dep.name}@${dep.versionConstraint}`);
await q.getAndSendList(after, a => (a.devDependencies || []).map(d => asRef(d)),
dep => `${dep.name}@${dep.versionConstraint}`);
await q.getAndSendList(after, a => (a.peerDependencies || []).map(d => asRef(d)),
dep => `${dep.name}@${dep.versionConstraint}`);
await q.getAndSendList(after, a => (a.optionalDependencies || []).map(d => asRef(d)),
dep => `${dep.name}@${dep.versionConstraint}`);
await q.getAndSend(after, a => a.engines);
await q.getAndSend(after, a => a.license);
}
});
/**
* Register RPC codec for NodeResolutionResult marker.
* This handles serialization/deserialization for communication between JS and Java.
*/
RpcCodecs.registerCodec(NodeResolutionResultKind, {
async rpcReceive(before: NodeResolutionResult, q: RpcReceiveQueue): Promise {
return updateIfChanged(before, {
id: await q.receive(before.id),
name: await q.receive(before.name),
version: await q.receive(before.version),
description: await q.receive(before.description),
path: await q.receive(before.path),
workspacePackagePaths: await q.receive(before.workspacePackagePaths),
dependencies: (await q.receiveList(before.dependencies)) || [],
devDependencies: (await q.receiveList(before.devDependencies)) || [],
peerDependencies: (await q.receiveList(before.peerDependencies)) || [],
optionalDependencies: (await q.receiveList(before.optionalDependencies)) || [],
bundledDependencies: (await q.receiveList(before.bundledDependencies)) || [],
resolvedDependencies: (await q.receiveList(before.resolvedDependencies)) || [],
packageManager: await q.receive(before.packageManager),
engines: await q.receive(before.engines),
npmrcConfigs: (await q.receiveList(before.npmrcConfigs)) || undefined,
});
},
async rpcSend(after: NodeResolutionResult, q: RpcSendQueue): Promise {
await q.getAndSend(after, a => a.id);
await q.getAndSend(after, a => a.name);
await q.getAndSend(after, a => a.version);
await q.getAndSend(after, a => a.description);
await q.getAndSend(after, a => a.path);
await q.getAndSend(after, a => a.workspacePackagePaths);
await q.getAndSendList(after, a => a.dependencies.map(d => asRef(d)),
dep => `${dep.name}@${dep.versionConstraint}`);
await q.getAndSendList(after, a => a.devDependencies.map(d => asRef(d)),
dep => `${dep.name}@${dep.versionConstraint}`);
await q.getAndSendList(after, a => a.peerDependencies.map(d => asRef(d)),
dep => `${dep.name}@${dep.versionConstraint}`);
await q.getAndSendList(after, a => a.optionalDependencies.map(d => asRef(d)),
dep => `${dep.name}@${dep.versionConstraint}`);
await q.getAndSendList(after, a => a.bundledDependencies.map(d => asRef(d)),
dep => `${dep.name}@${dep.versionConstraint}`);
await q.getAndSendList(after, a => a.resolvedDependencies.map(r => asRef(r)),
resolved => `${resolved.name}@${resolved.version}`);
await q.getAndSend(after, a => a.packageManager);
await q.getAndSend(after, a => a.engines);
await q.getAndSendList(after, a => a.npmrcConfigs || [],
npmrc => npmrc.scope);
}
});