/*
* 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 {Parser, ParserInput, parserInputFile, parserInputRead, ParserOptions, Parsers} from "../parser";
import {SourceFile} from "../tree";
import {Json, JsonParser} from "../json";
import {
createNodeResolutionResultMarker,
NodeResolutionResult,
PackageLockContent,
PackageLockEntry,
PackageManager,
readNpmrcConfigs
} from "./node-resolution-result";
import * as fs from "fs";
import * as fsp from "fs/promises";
import * as path from "path";
import * as YAML from "yaml";
import {getLockFileDetectionConfig, runList} from "./package-manager";
import picomatch from "picomatch";
import {DEFAULT_DIR_EXCLUSIONS, walkDirs} from "../path-utils";
/**
* Bun.lock package entry metadata.
*/
interface BunLockMetadata {
readonly dependencies?: Record;
readonly devDependencies?: Record;
readonly peerDependencies?: Record;
readonly optionalDependencies?: Record;
}
/**
* Bun.lock package entry: [name@version, url, metadata, integrity]
* Note: Using unknown for first element since we need runtime validation of parsed JSON.
*/
type BunLockPackageEntry = [unknown, string?, BunLockMetadata?, string?];
/**
* Parsed bun.lock content structure.
*/
interface BunLockContent {
readonly packages?: Record;
}
export interface PackageJsonParserOptions extends ParserOptions {
/**
* If true, skips reading and parsing lock files for dependency resolution.
* The NodeResolutionResult marker will still be created, but without resolved dependencies.
*/
skipDependencyResolution?: boolean;
}
/**
* A parser for package.json files that wraps the JsonParser.
*
* Similar to how MavenParser wraps XmlParser in Java, this parser:
* - Parses package.json files as JSON documents
* - Attaches NodeResolutionResult markers with dependency information
* - Optionally reads corresponding lock files (package-lock.json, yarn.lock, etc.)
* to provide resolved dependency versions
*/
export class PackageJsonParser extends Parser {
private readonly jsonParser: JsonParser;
private readonly skipDependencyResolution: boolean;
/** Fields to copy from package.json that contain dependency maps */
private static readonly DEPENDENCY_FIELDS = [
'dependencies',
'devDependencies',
'peerDependencies',
'optionalDependencies'
] as const;
constructor(options: PackageJsonParserOptions = {}) {
super(options);
this.jsonParser = new JsonParser(options);
this.skipDependencyResolution = options.skipDependencyResolution ?? false;
}
/**
* Extracts package metadata from a package.json object into a lock file entry format.
* Copies version, dependency fields, engines, and license.
*/
private static extractPackageMetadata(pkgJson: any, fallbackVersion?: string): Record {
const entry: Record = {
version: pkgJson.version || fallbackVersion,
};
for (const field of PackageJsonParser.DEPENDENCY_FIELDS) {
if (pkgJson[field] && Object.keys(pkgJson[field]).length > 0) {
entry[field] = pkgJson[field];
}
}
if (pkgJson.engines) {
entry.engines = pkgJson.engines;
}
if (pkgJson.license) {
// Normalize legacy license formats to string
entry.license = PackageJsonParser.normalizeLicense(pkgJson.license);
}
return entry;
}
/**
* Normalizes the license field from package.json.
* Older packages may have license in legacy formats:
* - Array: ["MIT", "Apache2"] -> "(MIT OR Apache2)"
* - Object: { type: "MIT", url: "..." } -> "MIT"
*/
private static normalizeLicense(license: any): string | undefined {
if (!license) return undefined;
if (Array.isArray(license)) {
return license.length > 0 ? `(${license.join(' OR ')})` : undefined;
}
if (typeof license === 'object' && license.type) {
return license.type;
}
return typeof license === 'string' ? license : undefined;
}
/**
* Accepts package.json files.
*/
accept(sourcePath: string): boolean {
const fileName = path.basename(sourcePath);
return fileName === 'package.json';
}
async *parse(...inputs: ParserInput[]): AsyncGenerator {
// Cache markers by directory to share NodeResolutionResult markers
// but maintain input order for output
const markersByDir = new Map();
// Process each input in order, caching markers per directory
for (const input of inputs) {
const filePath = parserInputFile(input);
const dir = path.dirname(filePath);
// Parse as JSON first
const jsonGenerator = this.jsonParser.parse(input);
const jsonResult = await jsonGenerator.next();
if (jsonResult.done || !jsonResult.value) {
continue;
}
const jsonDoc = jsonResult.value as Json.Document;
// Create NodeResolutionResult marker if not already created for this directory
if (!markersByDir.has(dir)) {
markersByDir.set(dir, await this.createMarker(input, dir));
}
const marker = markersByDir.get(dir)!;
// Attach the marker to the JSON document
if (marker) {
yield {
...jsonDoc,
markers: {
...jsonDoc.markers,
markers: [...jsonDoc.markers.markers, marker]
}
};
} else {
yield jsonDoc;
}
}
}
/**
* Creates a NodeResolutionResult marker from the package.json content and optional lock file.
*/
private async createMarker(input: ParserInput, dir: string): Promise {
try {
const content = parserInputRead(input);
const packageJson = JSON.parse(content);
// Determine the relative path for the marker
const filePath = parserInputFile(input);
const relativePath = this.relativeTo
? path.relative(this.relativeTo, filePath)
: filePath;
// Try to read lock file if dependency resolution is not skipped
// First try the directory containing the package.json, then walk up toward relativeTo
let lockContent: PackageLockContent | undefined = undefined;
let packageManager: PackageManager | undefined = undefined;
if (!this.skipDependencyResolution) {
// Resolve dir relative to relativeTo if dir is relative (e.g., '.' when package.json is at root)
const absoluteDir = this.relativeTo && !path.isAbsolute(dir)
? path.resolve(this.relativeTo, dir)
: dir;
const lockResult = await this.tryReadLockFileWithWalkUp(absoluteDir, this.relativeTo);
lockContent = lockResult?.content;
packageManager = lockResult?.packageManager;
}
// Read .npmrc configurations from all scopes
const projectDir = this.relativeTo || dir;
const npmrcConfigs = await readNpmrcConfigs(projectDir);
// Detect workspace member paths if this is a workspace root
// Check both package.json workspaces (npm/yarn) and pnpm-workspace.yaml (pnpm)
let workspacePackagePaths: string[] | undefined;
const absoluteDir = this.relativeTo && !path.isAbsolute(dir)
? path.resolve(this.relativeTo, dir)
: dir;
if (packageJson.workspaces) {
// npm/yarn style workspaces defined in package.json
workspacePackagePaths = await this.resolveWorkspacePackagePaths(
packageJson.workspaces,
absoluteDir,
this.relativeTo
);
} else if (packageManager === PackageManager.Pnpm) {
// pnpm workspaces defined in pnpm-workspace.yaml
const pnpmWorkspacePatterns = await this.readPnpmWorkspacePatterns(absoluteDir);
if (pnpmWorkspacePatterns && pnpmWorkspacePatterns.length > 0) {
workspacePackagePaths = await this.resolveWorkspacePackagePaths(
pnpmWorkspacePatterns,
absoluteDir,
this.relativeTo
);
}
}
return createNodeResolutionResultMarker(
relativePath,
packageJson,
lockContent,
workspacePackagePaths,
packageManager,
npmrcConfigs.length > 0 ? npmrcConfigs : undefined
);
} catch (error) {
console.warn(`Failed to create NodeResolutionResult marker: ${error}`);
return null;
}
}
/**
* Resolves workspace glob patterns to actual package.json paths.
*
* Workspaces can be specified as:
* - Array of globs: ["packages/*", "apps/*", "packages/**", "{apps,libs}/*"]
* - Object with packages array: { packages: ["packages/*"] }
* - Negation patterns: ["packages/*", "!packages/internal"]
*
* @param workspaces The workspaces field from package.json
* @param projectDir The absolute path to the project directory
* @param relativeTo Optional base path for creating relative paths
* @returns Array of relative paths to workspace member package.json files
*/
private async resolveWorkspacePackagePaths(
workspaces: string[] | { packages?: string[] },
projectDir: string,
relativeTo?: string
): Promise {
// Normalize workspaces to array format
const patterns = Array.isArray(workspaces)
? workspaces
: workspaces.packages;
if (!patterns || patterns.length === 0) {
return undefined;
}
// Separate include and exclude patterns
const includePatterns = patterns.filter(p => !p.startsWith('!'));
const excludePatterns = patterns.filter(p => p.startsWith('!')).map(p => p.slice(1));
// Create picomatch matchers
const isIncluded = includePatterns.length > 0
? picomatch(includePatterns, { dot: false })
: () => false;
const isExcluded = excludePatterns.length > 0
? picomatch(excludePatterns, { dot: false })
: () => false;
// Collect all candidate directories by walking the project
const candidateDirs = await this.collectCandidateWorkspaceDirs(projectDir, includePatterns);
const memberPaths: string[] = [];
const basePath = relativeTo || projectDir;
for (const candidateDir of candidateDirs) {
// Get relative path from project root for pattern matching
const relativeDir = path.relative(projectDir, candidateDir);
// Check if directory matches include patterns and not exclude patterns
if (isIncluded(relativeDir) && !isExcluded(relativeDir)) {
const packageJsonPath = path.join(candidateDir, 'package.json');
if (fs.existsSync(packageJsonPath)) {
const relativePath = path.relative(basePath, packageJsonPath);
memberPaths.push(relativePath);
}
}
}
return memberPaths.length > 0 ? memberPaths : undefined;
}
/**
* Collects candidate directories that might match workspace patterns.
* Uses the patterns to determine how deep to scan.
*/
private async collectCandidateWorkspaceDirs(
projectDir: string,
patterns: string[]
): Promise {
const candidates: string[] = [];
// Determine the maximum depth we need to scan based on patterns
// "packages/*" -> depth 1 under packages/
// "packages/**" -> unlimited depth under packages/
// "{apps,libs}/*" -> depth 1 under apps/ and libs/
for (const pattern of patterns) {
// Extract base directories from pattern (before any wildcards)
const baseDirs = this.extractBaseDirs(pattern);
const hasRecursive = pattern.includes('**');
for (const baseDir of baseDirs) {
const absoluteBaseDir = path.join(projectDir, baseDir);
if (!fs.existsSync(absoluteBaseDir)) {
continue;
}
// Use walkDirs with appropriate depth limit
const dirs = await walkDirs(absoluteBaseDir, {
maxDepth: hasRecursive ? undefined : 0,
excludeDirs: DEFAULT_DIR_EXCLUSIONS
});
candidates.push(...dirs);
}
}
return candidates;
}
/**
* Extracts base directory paths from a glob pattern.
* Handles brace expansion like "{apps,libs}/*" -> ["apps", "libs"]
*/
private extractBaseDirs(pattern: string): string[] {
// Find the first wildcard character
const wildcardIndex = pattern.search(/[*?]/);
const beforeWildcard = wildcardIndex >= 0 ? pattern.slice(0, wildcardIndex) : pattern;
// Remove trailing slash if present
const basePath = beforeWildcard.replace(/\/$/, '');
// Handle brace expansion at the end of basePath: "dir/{a,b}" or "{a,b}"
const braceMatch = basePath.match(/^(.*?)(?:\{([^}]+)\})?$/);
if (braceMatch && braceMatch[2]) {
const prefix = braceMatch[1];
const options = braceMatch[2].split(',').map(s => s.trim());
return options.map(opt => prefix + opt);
}
return basePath ? [basePath] : ['.'];
}
/**
* Reads workspace patterns from pnpm-workspace.yaml file.
*
* pnpm-workspace.yaml format:
* ```yaml
* packages:
* - 'packages/*'
* - 'apps/*'
* - '!packages/excluded'
* ```
*
* @param projectDir The absolute path to the project directory
* @returns Array of workspace patterns, or undefined if file doesn't exist
*/
private async readPnpmWorkspacePatterns(projectDir: string): Promise {
const pnpmWorkspacePath = path.join(projectDir, 'pnpm-workspace.yaml');
try {
const content = await fsp.readFile(pnpmWorkspacePath, 'utf-8');
const parsed = YAML.parse(content);
if (parsed && Array.isArray(parsed.packages)) {
return parsed.packages;
}
return undefined;
} catch {
// File doesn't exist or couldn't be parsed
return undefined;
}
}
/**
* Attempts to find and read a lock file by walking up the directory tree.
* Starts from the directory containing the package.json and walks up toward
* the root directory (or relativeTo if specified).
*
* This handles both standalone projects (lock file next to package.json) and
* workspace scenarios (lock file at workspace root).
*
* @param startDir The directory containing the package.json being parsed
* @param rootDir Optional root directory to stop walking at (e.g., relativeTo/git root)
* @returns Object with parsed lock file content and detected package manager, or undefined if none found
*/
private async tryReadLockFileWithWalkUp(
startDir: string,
rootDir?: string
): Promise<{ content: PackageLockContent; packageManager: PackageManager } | undefined> {
// Normalize paths for comparison
const normalizedRoot = rootDir ? path.resolve(rootDir) : undefined;
let currentDir = path.resolve(startDir);
// Walk up the directory tree looking for a lock file
while (true) {
const result = await this.tryReadLockFile(currentDir);
if (result) {
return result;
}
// If we've reached rootDir, stop walking (don't go above it)
if (normalizedRoot && currentDir === normalizedRoot) {
break;
}
// Check if we've reached the filesystem root
const parentDir = path.dirname(currentDir);
if (parentDir === currentDir) {
break;
}
currentDir = parentDir;
}
return undefined;
}
/**
* Attempts to read and parse a lock file from the given directory.
* Supports npm (package-lock.json), bun (bun.lock), pnpm, and yarn.
*
* @returns Object with parsed lock file content and detected package manager, or undefined if no lock file found
*/
private async tryReadLockFile(dir: string): Promise<{ content: PackageLockContent; packageManager: PackageManager } | undefined> {
// Use shared lock file detection config (first match wins based on priority)
for (const config of getLockFileDetectionConfig()) {
const lockPath = path.join(dir, config.filename);
if (!fs.existsSync(lockPath)) {
continue;
}
// Lock file exists - determine package manager
// Once we find a lock file, we commit to this package manager even if parsing fails
try {
const fileContent = await fsp.readFile(lockPath, 'utf-8');
const packageManager = typeof config.packageManager === 'function'
? config.packageManager(fileContent)
: config.packageManager;
// For package managers where lock file omits details, prefer node_modules
if (config.preferNodeModules) {
const parsed = await this.walkNodeModules(dir);
if (parsed) {
return { content: parsed, packageManager };
}
}
// Parse lock file based on package manager
const content = await this.parseLockFileContent(config.filename, fileContent, dir);
if (content) {
return { content, packageManager };
}
// Lock file exists but couldn't be parsed - still return the package manager
// This ensures we use the correct package manager even without resolved dependencies
return { content: { lockfileVersion: 0, packages: {} }, packageManager };
} catch (error) {
console.debug?.(`Failed to parse ${config.filename}: ${error}`);
// Even on error, we found this lock file - return the package manager with empty content
const packageManager = typeof config.packageManager === 'function'
? PackageManager.YarnClassic // Default for yarn.lock if we can't read content
: config.packageManager;
return { content: { lockfileVersion: 0, packages: {} }, packageManager };
}
}
// Fallback: if node_modules exists but no lock file was found (e.g., symlinked from another workspace),
// walk node_modules to get resolved dependency information
const parsed = await this.walkNodeModules(dir);
if (parsed) {
// Assume npm as the default package manager when only node_modules exists
return { content: parsed, packageManager: PackageManager.Npm };
}
return undefined;
}
/**
* Parses lock file content based on the lock file type.
*/
private async parseLockFileContent(
filename: string,
content: string,
dir: string
): Promise {
switch (filename) {
case 'package-lock.json':
return JSON.parse(content);
case 'bun.lock':
return this.convertBunLockToNpmFormat(this.parseJsonc(content) as BunLockContent);
case 'pnpm-lock.yaml':
// Fall back to pnpm CLI when node_modules unavailable
return this.getPnpmDependencies(dir);
case 'yarn.lock':
return this.parseYarnLock(content);
default:
return undefined;
}
}
/**
* Parses JSONC (JSON with Comments and trailing commas) content.
*/
private parseJsonc(content: string): Record {
const {parse} = require('jsonc-parser');
return parse(content);
}
/**
* Walks the node_modules directory to build an npm-format packages structure.
* This provides 100% accurate resolution for all package managers since it reads
* the actual installed packages rather than trying to interpret lock file formats.
*
* @param dir The project directory containing node_modules
* @returns npm package-lock.json format with packages map, or undefined if node_modules doesn't exist
*/
private async walkNodeModules(dir: string): Promise {
const nodeModulesPath = path.join(dir, 'node_modules');
if (!fs.existsSync(nodeModulesPath)) {
return undefined;
}
const packages: Record = {
"": {} // Root package placeholder
};
// Check if this is a pnpm project (has .pnpm directory)
const pnpmPath = path.join(nodeModulesPath, '.pnpm');
if (fs.existsSync(pnpmPath)) {
await this.walkPnpmNodeModules(pnpmPath, packages);
} else {
await this.walkNodeModulesRecursive(nodeModulesPath, 'node_modules', packages);
}
return Object.keys(packages).length > 1 ? {
lockfileVersion: 3,
packages
} : undefined;
}
/**
* Walks pnpm's .pnpm directory structure to build packages map.
* pnpm stores packages in .pnpm/@/node_modules//
*/
private async walkPnpmNodeModules(pnpmPath: string, packages: Record): Promise {
let entries: fs.Dirent[];
try {
entries = await fsp.readdir(pnpmPath, { withFileTypes: true });
} catch {
return;
}
// Process entries in parallel for better performance
await Promise.all(entries.map(async (entry) => {
// Skip non-directories and special files
if (!entry.isDirectory() || entry.name === 'node_modules') {
return;
}
// Parse name@version from directory name
// pnpm directory format: @ or @_
// Handle scoped packages: @scope+name@version
// Example: @babel+helper-module-transforms@7.28.3_@babel+core@7.28.5
// -> name: @babel/helper-module-transforms, version: 7.28.3
// First, strip peer dependency context (everything after first _)
const underscoreIndex = entry.name.indexOf('_');
const mainPart = underscoreIndex > 0 ? entry.name.substring(0, underscoreIndex) : entry.name;
// Now parse name@version from the main part
const atIndex = mainPart.lastIndexOf('@');
if (atIndex <= 0) return;
let name = mainPart.substring(0, atIndex);
const version = mainPart.substring(atIndex + 1);
// pnpm encodes @ as + in scoped packages: @scope+name -> @scope/name
if (name.startsWith('@') && name.includes('+')) {
name = name.replace('+', '/');
}
// The actual package is at .pnpm/@/node_modules//
const pkgDir = path.join(pnpmPath, entry.name, 'node_modules', name.replace('/', path.sep));
const packageJsonPath = path.join(pkgDir, 'package.json');
let pkgJson: any;
try {
const content = await fsp.readFile(packageJsonPath, 'utf-8');
pkgJson = JSON.parse(content);
} catch {
return;
}
// Use name@version as the key for pnpm (flat structure with version)
const pkgKey = `node_modules/${name}@${version}`;
packages[pkgKey] = PackageJsonParser.extractPackageMetadata(pkgJson, version);
}));
}
/**
* Recursively walks a node_modules directory, reading package.json files
* and building the packages map.
*
* @param nodeModulesPath Absolute path to the node_modules directory
* @param relativePath Relative path from project root (e.g., "node_modules" or "node_modules/foo/node_modules")
* @param packages The packages map to populate
*/
private async walkNodeModulesRecursive(
nodeModulesPath: string,
relativePath: string,
packages: Record
): Promise {
let entries: fs.Dirent[];
try {
entries = await fsp.readdir(nodeModulesPath, { withFileTypes: true });
} catch {
return; // Directory not readable
}
// Process entries in parallel for better performance
await Promise.all(entries.map(async (entry) => {
// Skip hidden files
if (entry.name.startsWith('.')) {
return;
}
// Accept directories and symlinks (pnpm uses symlinks)
const isDirectoryOrSymlink = entry.isDirectory() || entry.isSymbolicLink();
if (!isDirectoryOrSymlink) {
return;
}
// Handle scoped packages (@scope/name)
if (entry.name.startsWith('@')) {
const scopePath = path.join(nodeModulesPath, entry.name);
let scopeEntries: fs.Dirent[];
try {
scopeEntries = await fsp.readdir(scopePath, { withFileTypes: true });
} catch {
return;
}
await Promise.all(scopeEntries.map(async (scopeEntry) => {
// Accept directories and symlinks for scoped packages too
if (!scopeEntry.isDirectory() && !scopeEntry.isSymbolicLink()) return;
const scopedName = `${entry.name}/${scopeEntry.name}`;
const pkgPath = path.join(scopePath, scopeEntry.name);
await this.processPackage(pkgPath, `${relativePath}/${scopedName}`, packages);
}));
} else {
const pkgPath = path.join(nodeModulesPath, entry.name);
await this.processPackage(pkgPath, `${relativePath}/${entry.name}`, packages);
}
}));
}
/**
* Processes a single package directory, reading its package.json and
* recursively processing nested node_modules.
*/
private async processPackage(
pkgPath: string,
relativePath: string,
packages: Record
): Promise {
const packageJsonPath = path.join(pkgPath, 'package.json');
// Read and parse the package's package.json
let pkgJson: any;
try {
const content = await fsp.readFile(packageJsonPath, 'utf-8');
pkgJson = JSON.parse(content);
} catch {
return; // Not a valid package
}
packages[relativePath] = PackageJsonParser.extractPackageMetadata(pkgJson);
// Recursively process nested node_modules
const nestedNodeModules = path.join(pkgPath, 'node_modules');
try {
await fsp.access(nestedNodeModules);
await this.walkNodeModulesRecursive(nestedNodeModules, `${relativePath}/node_modules`, packages);
} catch {
// No nested node_modules, that's fine
}
}
/**
* Converts bun.lock format to npm package-lock.json format for unified processing.
*
* bun.lock format (v1):
* - Keys are package names or paths like "is-even/is-odd" for nested deps
* - Values are arrays: [name@version, url, metadata, integrity]
* - metadata can have: { dependencies: {...}, devDependencies: {...}, ... }
*/
private convertBunLockToNpmFormat(bunLock: BunLockContent): PackageLockContent | undefined {
if (!bunLock.packages) {
return undefined;
}
const packages: Record = {
"": {} // Root package placeholder
};
for (const [key, value] of Object.entries(bunLock.packages)) {
// bun.lock array format: [name@version, url, metadata, integrity]
const [nameAtVersion, , metadata] = value;
if (typeof nameAtVersion !== 'string') continue;
// Parse name@version from first element
const atIndex = nameAtVersion.lastIndexOf('@');
if (atIndex <= 0) continue;
const name = nameAtVersion.substring(0, atIndex);
const version = nameAtVersion.substring(atIndex + 1);
const pkgEntry: PackageLockEntry = {
version,
dependencies: metadata?.dependencies && Object.keys(metadata.dependencies).length > 0
? metadata.dependencies : undefined,
devDependencies: metadata?.devDependencies && Object.keys(metadata.devDependencies).length > 0
? metadata.devDependencies : undefined,
peerDependencies: metadata?.peerDependencies && Object.keys(metadata.peerDependencies).length > 0
? metadata.peerDependencies : undefined,
optionalDependencies: metadata?.optionalDependencies && Object.keys(metadata.optionalDependencies).length > 0
? metadata.optionalDependencies : undefined,
};
// Convert bun's path format to npm's node_modules format
// bun uses "parent/child" for nested deps, npm uses "node_modules/parent/node_modules/child"
let pkgPath: string;
if (key.includes('/')) {
// Nested dependency - convert "is-even/is-odd" to "node_modules/is-even/node_modules/is-odd"
const parts = key.split('/');
pkgPath = parts.map(p => `node_modules/${p}`).join('/');
} else {
pkgPath = `node_modules/${name}`;
}
packages[pkgPath] = pkgEntry;
}
return {
lockfileVersion: 3,
packages
};
}
/**
* Gets dependency information from pnpm using its CLI.
* Uses `pnpm list --json --depth=Infinity` to get the full dependency tree.
*/
private getPnpmDependencies(dir: string): Record | undefined {
const output = runList(PackageManager.Pnpm, dir, 30000);
if (!output) {
return undefined;
}
const pnpmList = JSON.parse(output);
return this.convertPnpmListToNpmFormat(pnpmList);
}
/**
* Converts pnpm list --json output to npm package-lock.json format.
*/
private convertPnpmListToNpmFormat(pnpmList: any): Record | undefined {
const packages: Record = {
"": {} // Root package placeholder
};
// pnpm list returns an array of projects (for workspaces) or a single object
const projects = Array.isArray(pnpmList) ? pnpmList : [pnpmList];
for (const project of projects) {
this.extractPnpmDependencies(project.dependencies, packages);
this.extractPnpmDependencies(project.devDependencies, packages);
this.extractPnpmDependencies(project.optionalDependencies, packages);
}
return Object.keys(packages).length > 1 ? {
lockfileVersion: 3,
packages
} : undefined;
}
/**
* Recursively extracts dependencies from pnpm list output.
* Uses name@version as key to handle multiple versions of the same package.
*/
private extractPnpmDependencies(deps: any, packages: Record): void {
if (!deps) return;
for (const [name, info] of Object.entries(deps as Record)) {
const version = info.version;
if (!version) continue;
const pkgKey = `node_modules/${name}@${version}`;
if (!packages[pkgKey]) {
const pkgEntry: any = { version };
// Extract nested dependency version constraints
if (info.dependencies) {
const nestedDeps: Record = {};
for (const [depName, depInfo] of Object.entries(info.dependencies as Record)) {
nestedDeps[depName] = (depInfo as any).version || '*';
}
if (Object.keys(nestedDeps).length > 0) {
pkgEntry.dependencies = nestedDeps;
}
}
packages[pkgKey] = pkgEntry;
}
// Recursively process nested dependencies
if (info.dependencies) {
this.extractPnpmDependencies(info.dependencies, packages);
}
}
}
/**
* Parses yarn.lock file and returns npm-format content.
* Detects whether it's Yarn Classic (v1) or Yarn Berry (v2+) format.
*/
private parseYarnLock(content: string): any {
// Yarn Berry (v2+) has __metadata section at the start
if (content.includes('__metadata:')) {
return this.parseYarnBerryLock(content);
}
// Yarn Classic (v1) starts with "# yarn lockfile v1"
if (content.includes('# yarn lockfile v1')) {
return this.parseYarnClassicLock(content);
}
return undefined;
}
/**
* Parses Yarn Berry (v2+) yarn.lock file directly.
* Format is standard YAML with package entries like:
* "is-odd@npm:^3.0.1":
* version: 3.0.1
* resolution: "is-odd@npm:3.0.1"
* dependencies:
* is-number: "npm:^6.0.0"
*/
private parseYarnBerryLock(content: string): any {
const lock = YAML.parse(content);
if (!lock) return undefined;
const packages: Record = {
"": {} // Root package placeholder
};
for (const [key, entry] of Object.entries(lock as Record)) {
// Skip metadata and workspace entries
if (key === '__metadata' || key.includes('@workspace:')) continue;
if (!entry || typeof entry !== 'object') continue;
const version = entry.version;
if (!version) continue;
// Extract package name from resolution like "is-odd@npm:3.0.1"
let name: string;
if (entry.resolution) {
const npmIndex = entry.resolution.indexOf('@npm:');
if (npmIndex > 0) {
name = entry.resolution.substring(0, npmIndex);
} else {
continue;
}
} else {
// Fallback: parse from key like "is-odd@npm:^3.0.1"
const npmIndex = key.indexOf('@npm:');
if (npmIndex > 0) {
name = key.substring(0, npmIndex);
} else {
continue;
}
}
const pkgKey = `node_modules/${name}@${version}`;
// Skip if already processed (multiple version constraints can resolve to same version)
if (packages[pkgKey]) continue;
const pkgEntry: any = { version };
// Parse dependencies
if (entry.dependencies && typeof entry.dependencies === 'object') {
const deps: Record = {};
for (const [depName, depConstraint] of Object.entries(entry.dependencies as Record)) {
// Constraint is like "npm:^6.0.0" - strip the "npm:" prefix
deps[depName] = depConstraint.startsWith('npm:')
? depConstraint.substring(4)
: depConstraint;
}
if (Object.keys(deps).length > 0) {
pkgEntry.dependencies = deps;
}
}
packages[pkgKey] = pkgEntry;
}
return Object.keys(packages).length > 1 ? {
lockfileVersion: 3,
packages
} : undefined;
}
/**
* Parses Yarn Classic (v1) yarn.lock file directly.
* Format is a custom format (not standard YAML):
*
* is-odd@^3.0.1:
* version "3.0.1"
* resolved "https://..."
* integrity sha512-...
* dependencies:
* is-number "^6.0.0"
*/
private parseYarnClassicLock(content: string): any {
const packages: Record = {
"": {} // Root package placeholder
};
// Split into package blocks - each block starts with an unindented line ending with ":"
// and may span multiple version constraints (e.g., "pkg@^1.0.0, pkg@^1.2.0:")
const lines = content.split('\n');
let currentNames: string[] = [];
let currentVersion: string | null = null;
let currentDeps: Record = {};
let inDependencies = false;
for (const line of lines) {
// Skip comments and empty lines
if (line.startsWith('#') || line.trim() === '') {
continue;
}
// New package block (unindented line ending with ":")
if (!line.startsWith(' ') && line.endsWith(':')) {
// Save previous package if exists
if (currentNames.length > 0 && currentVersion) {
const pkgKey = `node_modules/${currentNames[0]}@${currentVersion}`;
if (!packages[pkgKey]) {
const pkgEntry: any = { version: currentVersion };
if (Object.keys(currentDeps).length > 0) {
pkgEntry.dependencies = currentDeps;
}
packages[pkgKey] = pkgEntry;
}
}
// Parse new package names from line like 'is-odd@^3.0.1, is-odd@^3.0.0:'
// or '"@babel/core@^7.0.0":'
const namesStr = line.slice(0, -1); // Remove trailing ":"
currentNames = [];
// Split by ", " but handle quoted strings
const parts = namesStr.split(/,\s*(?=(?:[^"]*"[^"]*")*[^"]*$)/);
for (const part of parts) {
// Remove surrounding quotes if present
let cleaned = part.trim();
if (cleaned.startsWith('"') && cleaned.endsWith('"')) {
cleaned = cleaned.slice(1, -1);
}
// Extract package name (everything before last @)
const atIndex = cleaned.lastIndexOf('@');
if (atIndex > 0) {
currentNames.push(cleaned.substring(0, atIndex));
}
}
currentVersion = null;
currentDeps = {};
inDependencies = false;
continue;
}
// Version line: ' version "3.0.1"'
const versionMatch = line.match(/^\s+version\s+"([^"]+)"/);
if (versionMatch) {
currentVersion = versionMatch[1];
continue;
}
// Dependencies section start
if (line.match(/^\s+dependencies:\s*$/)) {
inDependencies = true;
continue;
}
// Other section (resolved, integrity, etc.) - ends dependencies section
if (line.match(/^\s+\w+:/) && !line.match(/^\s{4}/)) {
inDependencies = false;
continue;
}
// Dependency entry: ' is-number "^6.0.0"'
if (inDependencies) {
const depMatch = line.match(/^\s{4}(.+?)\s+"([^"]+)"/);
if (depMatch) {
currentDeps[depMatch[1]] = depMatch[2];
}
}
}
// Save last package
if (currentNames.length > 0 && currentVersion) {
const pkgKey = `node_modules/${currentNames[0]}@${currentVersion}`;
if (!packages[pkgKey]) {
const pkgEntry: any = { version: currentVersion };
if (Object.keys(currentDeps).length > 0) {
pkgEntry.dependencies = currentDeps;
}
packages[pkgKey] = pkgEntry;
}
}
return Object.keys(packages).length > 1 ? {
lockfileVersion: 3,
packages
} : undefined;
}
}
// Register with the Parsers registry for RPC support
Parsers.registerParser("packageJson", PackageJsonParser);