import fs from "node:fs"; import path from "node:path"; import { parse as parseYarnLock } from "yarn-lockfile"; import type { PackageRef } from "../types.js"; import { npmAliasRegistryName, packageInstanceKey } from "../utils/package-identity.js"; import { looksLikeVersion, normalizeRawVersion, isValidRange } from "../utils/version.js"; import { upsertPackage, MAX_PATHS_PER_PACKAGE } from "./utils.js"; import { resolveWorkspacePackageJsonPaths } from "../utils/package-json.js"; const MAX_PATH_DEPTH = 10; // A dependency-path BFS seed: a resolved lockfile key, the parent chain it hangs off // (["project"] for a root dep, ["project", ] for a workspace-package dep), and // whether the declaring edge is a devDependency. `dev` distinguishes production roots from // dev roots for reachability-based classification; it is not re-derived from path names. type Seed = { key: string; path: string[]; dev: boolean }; // A recorded display path for a package. Dev/prod is classified separately by production // reachability over the full dependency graph (see markYarnDevByProdReachability), not from // these retained paths - a production path can be dropped by the per-package cap, so deriving // dev from the capped display set would wrongly mark a still-production package as dev. type TaggedPath = { path: string[] }; // Record a display path for a package, matching the existing de-dup + cap behavior (keep the // first MAX_PATHS_PER_PACKAGE unique paths, in insertion order). function addTaggedPath( taggedPathsByKey: Map, key: string, path: string[], ): void { const list = taggedPathsByKey.get(key) ?? []; taggedPathsByKey.set(key, list); const serialized = path.join(">"); if (list.some(entry => entry.path.join(">") === serialized)) return; if (list.length >= MAX_PATHS_PER_PACKAGE) return; list.push({ path }); } // Write the collected display paths onto each package. Dev/prod is set separately. function applyTaggedPaths( map: Map, taggedPathsByKey: Map, ): void { for (const [key, tagged] of taggedPathsByKey) { const pkg = map.get(key); if (!pkg) continue; pkg.paths = tagged.map(entry => entry.path); } } // Classify dev vs prod by production reachability, mirroring the pnpm and Bun loaders: a // package that has recorded paths is dev only when it is not reachable from any production // root (a root or workspace dependency/optionalDependency). Reachability is computed over the // full graph, so it is independent of the per-package display-path cap - a production path // beyond the cap still keeps the package production. Unresolved fallback production deps are // seeded directly, since they are not graph nodes. function markYarnDevByProdReachability( map: Map, graph: Map, prodRootKeys: string[], prodFallbackKeys: Set, ): void { const prodReachable = new Set(prodFallbackKeys); const visited = new Set(); const stack = [...prodRootKeys]; while (stack.length > 0) { const key = stack.pop()!; if (visited.has(key)) continue; visited.add(key); prodReachable.add(key); for (const child of graph.get(key) ?? []) { if (!visited.has(child)) stack.push(child); } } for (const [key, pkg] of map) { if ((pkg.paths?.length ?? 0) === 0) continue; if (!prodReachable.has(key)) pkg.dev = true; } } function readRootPackageJson(filePath: string): any | null { try { return JSON.parse(fs.readFileSync(path.join(path.dirname(filePath), "package.json"), "utf8")); } catch { return null; } } export function buildYarnWorkspaceMap(filePath: string, options?: { unresolvedRangesOnly?: boolean }): Map { const map = new Map(); const members = readWorkspacePackages(filePath); const root = readRootPackageJson(filePath); const manifests = root && (members.length > 0 || options?.unresolvedRangesOnly) ? [{ name: ".", pkg: root }, ...members] : members; for (const { name: wsName, pkg: wsPkg } of manifests) { for (const depSection of ["dependencies", "optionalDependencies", "devDependencies"]) { const deps = wsPkg[depSection]; if (!deps || typeof deps !== "object") continue; for (const depName of Object.keys(deps)) { // Unsupported protocols cannot rule out an installed version. Preserve // the declaring workspace rather than treating missing resolution as a mismatch. if (options?.unresolvedRangesOnly && (typeof deps[depName] !== "string" || isValidRange(deps[depName].replace(/^npm:/, "")) || npmAliasRegistryName(deps[depName]))) continue; const existing = map.get(depName) ?? []; if (!existing.includes(wsName)) map.set(depName, [...existing, wsName]); } } } return map; } // Enumerate a project's Yarn workspace packages (name + parsed package.json) by // resolving the root package.json's `workspaces` globs. Shared by the workspace-map // builder and by dependency-path seeding, so a transitive pulled in only by a // workspace package can be attributed back to that workspace instead of orphaning. function readWorkspacePackages(filePath: string, rootPkg?: any): { name: string; pkg: any }[] { const dir = path.dirname(filePath); const root = rootPkg ?? readRootPackageJson(filePath); if (!root) return []; const patterns: string[] = Array.isArray(root.workspaces) ? root.workspaces : root.workspaces?.packages ?? []; if (!patterns.length) return []; const result: { name: string; pkg: any }[] = []; for (const packageJsonPath of resolveWorkspacePackageJsonPaths(dir, patterns)) { let wsPkg: any; try { wsPkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); } catch { continue; } if (!wsPkg?.name) continue; result.push({ name: wsPkg.name, pkg: wsPkg }); } return result; } export function isYarnBerry(content: string): boolean { return content.startsWith('__metadata:') || content.includes('\n__metadata:'); } function pathsEqual(left: string[], right: string[]): boolean { if (left.length !== right.length) return false; return left.every((value, index) => value === right[index]); } export function parseYarnPackageKey(key: string): { key: string; name: string; version: string } | null { const idx = key.lastIndexOf("@"); if (idx <= 0) return null; const name = key.slice(0, idx); const version = key.slice(idx + 1); if (!name || !version) return null; return { key, name, version }; } /** * Yarn v1 records an alias as `@npm:@`. * * `yarn 1.22` quotes that key. A scoped target keeps the leading `@` after the * protocol (`frames@npm:@babel/code-frame@7.23.5`), and a scoped alias keeps * its own (`@my/frames@npm:@babel/code-frame@7.23.5`). The range in the key is * what was requested. The installed version is the lockfile `version` field, * so this only returns names. * * A normal key (`lodash@4.17.20`, `@babel/code-frame@^7.23.4`) has no `@npm:` * marker. Splitting those on the final `@` is still the package name. Doing * that to an alias key yields `util-lib@npm:lodash`, which is not a package * OSV knows about. */ export function parseYarnClassicDescriptor(selector: string): { name: string; registryName?: string } | null { const marker = "@npm:"; const markerAt = selector.indexOf(marker); if (markerAt > 0) { const name = selector.slice(0, markerAt); const target = selector.slice(markerAt + marker.length); if (!name || !target) return null; // One definition of an alias target, shared with npm lockfiles: a later `@` // starts the requested range, and the leading `@` of `@scope/pkg` does not. const registryName = npmAliasRegistryName("npm:" + target); if (!registryName) return null; // `lodash@npm:lodash@4.17.20` is the same package under its own name. if (registryName === name) return { name }; return { name, registryName }; } const atIndex = selector.lastIndexOf("@"); if (atIndex <= 0) return null; const name = selector.slice(0, atIndex); if (!name) return null; return { name }; } export type YarnClassicIdentity = { name: string; registryName?: string; selectors: string[]; }; /** Every descriptor in one Yarn v1 entry, and the package the first one names. */ export function parseYarnClassicSelectorKey(selectorKey: string): YarnClassicIdentity | null { const selectors = String(selectorKey).split(",").map(part => part.trim()).filter(Boolean); const identity = selectors[0] ? parseYarnClassicDescriptor(selectors[0]) : null; if (!identity) return null; return { name: identity.name, ...(identity.registryName ? { registryName: identity.registryName } : {}), selectors, }; } type YarnClassicEntry = YarnClassicIdentity & { version: string; /** Map and path-graph key. Includes the registry name when this is an alias. */ instanceKey: string; }; function readYarnClassicEntry(selectorKey: string, meta: { version?: unknown }): YarnClassicEntry | null { const version = meta?.version; if (typeof version !== "string" || version.length === 0) return null; const identity = parseYarnClassicSelectorKey(selectorKey); if (!identity) return null; return { ...identity, version, instanceKey: packageInstanceKey({ name: identity.name, registryName: identity.registryName, version, }), }; } function collectYarnPaths( seeds: Seed[], graph: Map, map: Map, taggedPathsByKey: Map, ): void { const queue = seeds.map(seed => ({ key: seed.key, path: seed.path, depth: 0, ancestors: [] as string[] })); const visitedStates = new Set(); let head = 0; while (head < queue.length) { const current = queue[head++]!; const ref = parseYarnPackageKey(current.key); if (!ref || current.ancestors.includes(ref.key)) continue; // An aliased entry's canonical key is `alias\0registry@version`. The path // shows the name that was installed, which is the part before the NUL. const installName = ref.name.split("\0")[0]; if (!installName) continue; const nextPath = [...current.path, installName]; // Preserve reachability before display-path de-duplication: registry and // workspace parents can produce exactly the same printable path. const pkg = map.get(ref.key); if (pkg && current.depth > 0) { const paths = pkg.yarnTransitivePaths ??= []; if (paths.length < MAX_PATHS_PER_PACKAGE && !paths.some(p => pathsEqual(p, nextPath))) paths.push(nextPath); } const stateKey = `${ref.key}>${nextPath.join(">")}`; if (visitedStates.has(stateKey)) continue; visitedStates.add(stateKey); // Only record paths for packages that exist in the lockfile map. if (map.has(ref.key)) addTaggedPath(taggedPathsByKey, ref.key, nextPath); if (nextPath.length >= MAX_PATH_DEPTH) continue; const recorded = taggedPathsByKey.get(ref.key) ?? []; if (recorded.length >= MAX_PATHS_PER_PACKAGE && !recorded.some(entry => pathsEqual(entry.path, nextPath))) { continue; } for (const child of graph.get(ref.key) ?? []) { queue.push({ key: child, path: nextPath, depth: current.depth + 1, ancestors: [...current.ancestors, ref.key] }); } } } // Resolve one package.json's dependency declarations into BFS seeds. The pathPrefix is // the parent chain the seed hangs off: ["project"] for the root, ["project", ] // for a workspace package. Unresolved direct deps (present in package.json but not the // lockfile) fall back to a shallow path so they are still attributed. function resolveDepSeeds( pkgJson: any, pathPrefix: string[], selectorToKey: Map, map: Map, taggedPathsByKey: Map, prodFallbackKeys: Set, ): Seed[] { const seeds: Seed[] = []; for (const depSection of ["dependencies", "optionalDependencies", "devDependencies"]) { const deps = pkgJson?.[depSection]; if (!deps || typeof deps !== "object") continue; const dev = depSection === "devDependencies"; for (const [depName, depRange] of Object.entries(deps)) { const resolved = selectorToKey.get(`${depName}@${depRange}`) ?? selectorToKey.get(`${depName}@npm:${depRange}`); if (resolved) { if (pathPrefix.length === 2) { const direct = map.get(resolved); if (direct) direct.workspaceDirect = true; } const direct = map.get(resolved); if (direct) { const workspaces = direct.yarnDirectWorkspaces ??= []; const workspace = pathPrefix[1] ?? "."; if (!workspaces.includes(workspace)) workspaces.push(workspace); } seeds.push({ key: resolved, path: pathPrefix, dev }); continue; } const fallbackVersion = normalizeRawVersion(depRange); if (fallbackVersion) { const name = String(depName); const key = `${name}@${fallbackVersion}`; upsertPackage(map, { name, version: fallbackVersion, ecosystem: "npm", paths: [] }); const direct = map.get(key)!; if (pathPrefix.length === 2) direct.workspaceDirect = true; const workspaces = direct.yarnDirectWorkspaces ??= []; const workspace = pathPrefix[1] ?? "."; if (!workspaces.includes(workspace)) workspaces.push(workspace); addTaggedPath(taggedPathsByKey, key, [...pathPrefix, name]); // Unresolved fallback prod deps are not graph nodes, so record their key as // production-reachable directly (mirrors the resolved production roots). if (!dev) prodFallbackKeys.add(key); } } } return seeds; } // Build the BFS seeds for a project. Seeds come from the root package.json (parent chain // ["project"]) and from every workspace package (parent chain ["project", ]), // so transitives reachable only through a workspace resolve back to that workspace instead // of reporting "No parent identified". Each seed carries whether its declaring edge is a // devDependency (used to pick the production roots for reachability-based classification); // prodFallbackKeys collects unresolved production direct deps that are not graph nodes. function loadSeeds( filePath: string, selectorToKey: Map, map: Map, taggedPathsByKey: Map, prodFallbackKeys: Set, ): Seed[] { const rootPkg = readRootPackageJson(filePath); if (!rootPkg) return []; const seeds = resolveDepSeeds(rootPkg, ["project"], selectorToKey, map, taggedPathsByKey, prodFallbackKeys); for (const { name: wsName, pkg: wsPkg } of readWorkspacePackages(filePath, rootPkg)) { seeds.push(...resolveDepSeeds(wsPkg, ["project", wsName], selectorToKey, map, taggedPathsByKey, prodFallbackKeys)); } return seeds; } export function parseBerryDependencies(block: string): Record { const dependencies: Record = {}; const lines = block.split("\n"); let inDependencies = false; for (const line of lines) { if (/^\s*dependencies:\s*$/.test(line)) { inDependencies = true; continue; } if (!inDependencies) continue; const depMatch = line.match(/^\s{2,}([^:]+):\s*(.+)$/); if (!depMatch) { if (!/^\s/.test(line)) inDependencies = false; continue; } dependencies[depMatch[1].trim().replace(/^"|"$/g, "")] = depMatch[2].trim().replace(/^"|"$/g, ""); } return dependencies; } function loadFromYarnBerryLock(content: string, filePath: string): PackageRef[] { const map = new Map(); const graph = new Map(); const selectorToKey = new Map(); const blocks = content.split(/\n\n+/); for (const block of blocks) { const lines = block.trim().split('\n'); if (!lines[0] || lines[0].startsWith('__metadata')) continue; const selectorLine = lines[0].trim().replace(/:$/, "").replace(/^"|"$/g, ""); const resolutionLine = lines.find((l) => l.trim().startsWith('resolution:')); if (!resolutionLine) continue; const resMatch = resolutionLine.match(/resolution:\s+"(.+)"/); if (!resMatch) continue; const resolution = resMatch[1]; const npmIdx = resolution.lastIndexOf('@npm:'); if (npmIdx < 0) continue; const name = resolution.slice(0, npmIdx); const version = resolution.slice(npmIdx + 5); if (!name || !version) continue; const canonicalKey = `${name}@${version}`; for (const selector of selectorLine.split(",").map(part => part.trim()).filter(Boolean)) { selectorToKey.set(selector, canonicalKey); } upsertPackage(map, { name, version, ecosystem: 'npm', paths: [] }); } for (const block of blocks) { const lines = block.trim().split('\n'); if (!lines[0] || lines[0].startsWith('__metadata')) continue; const selectorLine = lines[0].trim().replace(/:$/, "").replace(/^"|"$/g, ""); const canonicalKey = selectorToKey.get( selectorLine.split(",").map(part => part.trim()).find(part => selectorToKey.has(part)) ?? selectorLine, ) ?? selectorToKey.get(selectorLine); if (!canonicalKey) continue; const depKeys = new Set(); for (const [depName, depRef] of Object.entries(parseBerryDependencies(block))) { const childKey = selectorToKey.get(`${depName}@${depRef}`) ?? selectorToKey.get(`${depName}@npm:${depRef}`); if (childKey) depKeys.add(childKey); } graph.set(canonicalKey, [...depKeys]); } const taggedPathsByKey = new Map(); const prodFallbackKeys = new Set(); const seeds = loadSeeds(filePath, selectorToKey, map, taggedPathsByKey, prodFallbackKeys); collectYarnPaths(seeds, graph, map, taggedPathsByKey); applyTaggedPaths(map, taggedPathsByKey); markYarnDevByProdReachability( map, graph, seeds.filter(seed => !seed.dev).map(seed => seed.key), prodFallbackKeys, ); return [...map.values()]; } function loadFromYarnClassicLock(parsed: any, filePath: string): PackageRef[] { const map = new Map(); const graph = new Map(); const selectorToKey = new Map(); for (const [selectorKey, meta] of Object.entries(parsed.object)) { const entry = readYarnClassicEntry(selectorKey, meta); if (!entry) continue; for (const selector of entry.selectors) { selectorToKey.set(selector, entry.instanceKey); } upsertPackage(map, { name: entry.name, version: entry.version, ecosystem: "npm", paths: [], resolvedUrl: meta?.resolved as string | undefined, ...(entry.registryName ? { registryName: entry.registryName } : {}), }); } for (const [selectorKey, meta] of Object.entries(parsed.object)) { const entry = readYarnClassicEntry(selectorKey, meta); if (!entry) continue; const depKeys = new Set(); const deps = meta?.dependencies; if (deps && typeof deps === "object") { for (const [depName, depRange] of Object.entries(deps)) { const childKey = selectorToKey.get(`${depName}@${depRange}`); if (childKey) depKeys.add(childKey); } } graph.set(entry.instanceKey, [...depKeys]); } const taggedPathsByKey = new Map(); const prodFallbackKeys = new Set(); const seeds = loadSeeds(filePath, selectorToKey, map, taggedPathsByKey, prodFallbackKeys); collectYarnPaths(seeds, graph, map, taggedPathsByKey); applyTaggedPaths(map, taggedPathsByKey); markYarnDevByProdReachability( map, graph, seeds.filter(seed => !seed.dev).map(seed => seed.key), prodFallbackKeys, ); return [...map.values()]; } export function loadFromYarnLock(filePath: string): PackageRef[] { const content = fs.readFileSync(filePath, "utf8"); if (isYarnBerry(content)) { return loadFromYarnBerryLock(content, filePath); } const parsed = parseYarnLock(content) as any; if (parsed.type !== "success" || !parsed.object) { throw new Error("Could not parse yarn.lock"); } return loadFromYarnClassicLock(parsed, filePath); }