From c30514109d59578b4fa16a9adf1abfd6b4edfea0 Mon Sep 17 00:00:00 2001 From: Dario Piotrowicz Date: Wed, 15 Jul 2026 10:43:40 +0100 Subject: [PATCH 1/4] Resolve installed dependency versions from lockfiles instead of node_modules `getInstalledPackageVersion` now consults the nearest lockfile (pnpm-lock.yaml, package-lock.json, yarn.lock, or bun.lock) before falling back to resolving from node_modules. The lockfile is parsed once and memoized, making repeated version lookups significantly faster. This also improves the accuracy of the dependency metadata collected during deploys. All four major package managers (npm, pnpm, yarn, and bun) are supported across their lockfile format versions --- .changeset/lockfile-version-resolution.md | 8 + packages/workers-utils/package.json | 5 +- .../src/package-resolution/index.ts | 2 + .../lockfiles-resolution/bun.ts | 78 ++++ .../lockfiles-resolution/discovery.ts | 58 +++ .../lockfiles-resolution/index.ts | 1 + .../lockfiles-resolution/npm.ts | 54 +++ .../lockfiles-resolution/pnpm.ts | 147 ++++++++ .../lockfiles-resolution/resolve.ts | 115 ++++++ .../lockfiles-resolution/yarn.ts | 151 ++++++++ .../node-modules.ts} | 58 +-- .../package-resolution/package-resolution.ts | 50 +++ .../lockfile-resolution/bun.test.ts | 153 ++++++++ .../lockfile-resolution/npm.test.ts | 195 ++++++++++ .../lockfile-resolution/pnpm.test.ts | 348 ++++++++++++++++++ .../lockfile-resolution/resolve.test.ts | 219 +++++++++++ .../lockfile-resolution/share.ts | 12 + .../lockfile-resolution/yarn.test.ts | 235 ++++++++++++ .../package-resolution.test.ts | 337 +++++++++++++++++ pnpm-lock.yaml | 19 + 20 files changed, 2198 insertions(+), 47 deletions(-) create mode 100644 .changeset/lockfile-version-resolution.md create mode 100644 packages/workers-utils/src/package-resolution/index.ts create mode 100644 packages/workers-utils/src/package-resolution/lockfiles-resolution/bun.ts create mode 100644 packages/workers-utils/src/package-resolution/lockfiles-resolution/discovery.ts create mode 100644 packages/workers-utils/src/package-resolution/lockfiles-resolution/index.ts create mode 100644 packages/workers-utils/src/package-resolution/lockfiles-resolution/npm.ts create mode 100644 packages/workers-utils/src/package-resolution/lockfiles-resolution/pnpm.ts create mode 100644 packages/workers-utils/src/package-resolution/lockfiles-resolution/resolve.ts create mode 100644 packages/workers-utils/src/package-resolution/lockfiles-resolution/yarn.ts rename packages/workers-utils/src/{package-resolution.ts => package-resolution/node-modules.ts} (69%) create mode 100644 packages/workers-utils/src/package-resolution/package-resolution.ts create mode 100644 packages/workers-utils/tests/package-resolution/lockfile-resolution/bun.test.ts create mode 100644 packages/workers-utils/tests/package-resolution/lockfile-resolution/npm.test.ts create mode 100644 packages/workers-utils/tests/package-resolution/lockfile-resolution/pnpm.test.ts create mode 100644 packages/workers-utils/tests/package-resolution/lockfile-resolution/resolve.test.ts create mode 100644 packages/workers-utils/tests/package-resolution/lockfile-resolution/share.ts create mode 100644 packages/workers-utils/tests/package-resolution/lockfile-resolution/yarn.test.ts create mode 100644 packages/workers-utils/tests/package-resolution/package-resolution.test.ts diff --git a/.changeset/lockfile-version-resolution.md b/.changeset/lockfile-version-resolution.md new file mode 100644 index 0000000000..4351de39f2 --- /dev/null +++ b/.changeset/lockfile-version-resolution.md @@ -0,0 +1,8 @@ +--- +"@cloudflare/workers-utils": minor +"wrangler": patch +--- + +Resolve installed dependency versions from lockfiles instead of node_modules + +Dependency version resolution during deploys now reads from the project's lockfile (pnpm-lock.yaml, package-lock.json, yarn.lock, or bun.lock) before falling back to node_modules. This improves the accuracy of the dependency metadata reported in deploys and speeds up version lookups for projects with many dependencies. All four major package managers are supported. diff --git a/packages/workers-utils/package.json b/packages/workers-utils/package.json index d012ef4548..7d71aeac83 100644 --- a/packages/workers-utils/package.json +++ b/packages/workers-utils/package.json @@ -58,7 +58,9 @@ "@cloudflare/workflows-shared": "workspace:*", "@types/command-exists": "^1.2.0", "@types/node": "catalog:default", + "@types/yarnpkg__lockfile": "^1.1.9", "@vitest/ui": "catalog:default", + "@yarnpkg/lockfile": "^1.1.0", "ci-info": "catalog:default", "cloudflare": "^5.2.0", "command-exists": "catalog:default", @@ -74,7 +76,8 @@ "typescript": "catalog:default", "update-check": "^1.5.4", "vitest": "catalog:default", - "xdg-app-paths": "^8.3.0" + "xdg-app-paths": "^8.3.0", + "yaml": "^2.8.1" }, "peerDependencies": { "vitest": "^4.1.0" diff --git a/packages/workers-utils/src/package-resolution/index.ts b/packages/workers-utils/src/package-resolution/index.ts new file mode 100644 index 0000000000..fc28c2b1a0 --- /dev/null +++ b/packages/workers-utils/src/package-resolution/index.ts @@ -0,0 +1,2 @@ +export { getPackagePath, isPackageInstalled } from "./node-modules"; +export { getInstalledPackageVersion } from "./package-resolution"; diff --git a/packages/workers-utils/src/package-resolution/lockfiles-resolution/bun.ts b/packages/workers-utils/src/package-resolution/lockfiles-resolution/bun.ts new file mode 100644 index 0000000000..7d5cee38b6 --- /dev/null +++ b/packages/workers-utils/src/package-resolution/lockfiles-resolution/bun.ts @@ -0,0 +1,78 @@ +import * as jsoncParser from "jsonc-parser"; + +/** + * Parses a bun.lock file (JSONC format) and extracts dependency versions. + * + * Package entries in `bun.lock` are keyed by package name, with the value + * being a tuple where the first element is `"name@version"`. + * + * @param content - Raw JSONC content of the bun.lock file + * @returns A map of package names to resolved versions + */ +export function parseBunLockfile(content: string): Map { + const lockfile = jsoncParser.parse(content) as BunLockfile; + const versions = new Map(); + + if (!lockfile?.packages) { + return versions; + } + + for (const [key, entry] of Object.entries(lockfile.packages)) { + if (!Array.isArray(entry) || entry.length === 0) { + continue; + } + const resolvedId = entry[0] as string; + if (typeof resolvedId !== "string") { + continue; + } + + // resolvedId format: "name@version" or "@scope/name@version" + const resolvedName = extractBunPackageName(resolvedId); + const version = extractBunPackageVersion(resolvedId); + + if (!version) { + continue; + } + + // Alias detection: the key differs from the resolved package name + if (resolvedName && resolvedName !== key) { + continue; + } + + versions.set(key, version); + } + + return versions; +} + +/** + * Extracts the package name from a bun resolved ID string. + * + * @param resolvedId - A string like `"lodash@4.17.21"` or `"@scope/pkg@1.0.0"` + * @returns The package name portion + */ +function extractBunPackageName(resolvedId: string): string | undefined { + const lastAt = resolvedId.lastIndexOf("@"); + if (lastAt <= 0) { + return undefined; + } + return resolvedId.slice(0, lastAt); +} + +/** + * Extracts the version from a bun resolved ID string. + * + * @param resolvedId - A string like `"lodash@4.17.21"` or `"@scope/pkg@1.0.0"` + * @returns The version portion + */ +function extractBunPackageVersion(resolvedId: string): string | undefined { + const lastAt = resolvedId.lastIndexOf("@"); + if (lastAt <= 0) { + return undefined; + } + return resolvedId.slice(lastAt + 1); +} + +interface BunLockfile { + packages?: Record; +} diff --git a/packages/workers-utils/src/package-resolution/lockfiles-resolution/discovery.ts b/packages/workers-utils/src/package-resolution/lockfiles-resolution/discovery.ts new file mode 100644 index 0000000000..7f24eafb8b --- /dev/null +++ b/packages/workers-utils/src/package-resolution/lockfiles-resolution/discovery.ts @@ -0,0 +1,58 @@ +import { statSync } from "node:fs"; +import { join } from "node:path"; +import * as walk from "empathic/walk"; + +/** + * Lockfile file names in priority order. + * Used for walking up from the project directory to find the nearest lockfile. + * + * `bun.lockb` is binary and cannot be parsed in JS — omitted intentionally. + */ +export const LOCKFILE_NAMES = [ + "pnpm-lock.yaml", + "package-lock.json", + "yarn.lock", + "bun.lock", +] as const; + +/** + * The name of a lockfile that can be parsed. + */ +export type LockfileName = (typeof LOCKFILE_NAMES)[number]; + +/** + * Walks up the directory tree from `startDir` to find the nearest lockfile. + * + * At each directory level, checks all lockfile names in priority order before + * ascending to the parent. This ensures the nearest lockfile always wins, + * and name-based priority only breaks ties within the same directory. + * + * @param startDir - The directory to start searching from + * @param opts - Options + * @param opts.last - If set, stop walking after this directory (inclusive). + * Prevents discovery from reaching ancestor lockfiles. + * @returns The lockfile path and its filename, or `undefined` if none is found + */ +export function findLockfile( + startDir: string, + opts: { last?: string } = {} +): { lockfilePath: string; name: LockfileName } | undefined { + const dirs = walk.up(startDir, { + cwd: startDir, + last: opts.last, + }); + + for (const dir of dirs) { + for (const name of LOCKFILE_NAMES) { + const candidate = join(dir, name); + try { + if (statSync(candidate).isFile()) { + return { lockfilePath: candidate, name }; + } + } catch { + // File doesn't exist or isn't accessible — try the next name + } + } + } + return undefined; +} diff --git a/packages/workers-utils/src/package-resolution/lockfiles-resolution/index.ts b/packages/workers-utils/src/package-resolution/lockfiles-resolution/index.ts new file mode 100644 index 0000000000..7f997be002 --- /dev/null +++ b/packages/workers-utils/src/package-resolution/lockfiles-resolution/index.ts @@ -0,0 +1 @@ +export { getInstalledVersionsFromLockfile } from "./resolve"; diff --git a/packages/workers-utils/src/package-resolution/lockfiles-resolution/npm.ts b/packages/workers-utils/src/package-resolution/lockfiles-resolution/npm.ts new file mode 100644 index 0000000000..4910915d7e --- /dev/null +++ b/packages/workers-utils/src/package-resolution/lockfiles-resolution/npm.ts @@ -0,0 +1,54 @@ +/** + * Parses an npm package-lock.json and extracts top-level dependency versions. + * + * Supports lockfileVersion 1, 2, and 3. + * + * @param content - Raw JSON content of the lockfile + * @returns A map of package names to resolved versions + */ +export function parseNpmLockfile(content: string): Map { + const lockfile = JSON.parse(content) as NpmLockfile; + const versions = new Map(); + + if (lockfile.packages) { + // lockfileVersion 2 or 3 — top-level deps live under "node_modules/" + for (const [key, entry] of Object.entries(lockfile.packages)) { + if (!key.startsWith("node_modules/")) { + continue; + } + // Skip nested node_modules (transitive deps stored under a parent) + // e.g. "node_modules/foo/node_modules/bar" — only keep top-level + if (key.indexOf("/node_modules/", "node_modules/".length) !== -1) { + continue; + } + // Skip aliases: when `name` is set and differs from the key's package + // name, this is an npm alias (e.g. "my-react": "npm:react@^18") + const pkgName = key.slice("node_modules/".length); + if (entry.name && entry.name !== pkgName) { + continue; + } + if (entry.version) { + versions.set(pkgName, entry.version); + } + } + } else if (lockfile.dependencies) { + // lockfileVersion 1 fallback + for (const [pkgName, entry] of Object.entries(lockfile.dependencies)) { + // Skip aliases: version starts with "npm:" + if (entry.version?.startsWith("npm:")) { + continue; + } + if (entry.version) { + versions.set(pkgName, entry.version); + } + } + } + + return versions; +} + +interface NpmLockfile { + lockfileVersion?: number; + packages?: Record; + dependencies?: Record; +} diff --git a/packages/workers-utils/src/package-resolution/lockfiles-resolution/pnpm.ts b/packages/workers-utils/src/package-resolution/lockfiles-resolution/pnpm.ts new file mode 100644 index 0000000000..32be0b0a00 --- /dev/null +++ b/packages/workers-utils/src/package-resolution/lockfiles-resolution/pnpm.ts @@ -0,0 +1,147 @@ +import { parse as parseYaml } from "yaml"; + +/** + * Parses a pnpm-lock.yaml and extracts dependency versions for a specific + * workspace importer. + * + * Supports lockfileVersion 5.x, 6.x, and 9.x. Strips peer-dependency + * suffixes like `1.2.3(react@18)` (v6+) or `1.2.3_react@16.8.0` (v5) + * to extract the bare version. + * + * **v6/v9** — dependencies are listed under `importers..dependencies` + * and `importers..devDependencies` with `{ specifier, version }` entries. + * + * **v5 single-project** — there is no `importers` key. Dependencies sit at + * the top-level `dependencies`/`devDependencies` maps (values are plain + * version strings) and alias information is in a separate top-level + * `specifiers` map. + * + * @param content - Raw YAML content of the lockfile + * @param importerKey - The importer path relative to the lockfile root + * (e.g. "." for the root, "packages/foo" for a sub-package) + * @returns A map of package names to resolved versions + */ +export function parsePnpmLockfile( + content: string, + importerKey: string +): Map { + const lockfile = parseYaml(content) as PnpmLockfile; + const versions = new Map(); + + const importer = lockfile.importers?.[importerKey]; + if (importer) { + // v6/v9 path — entries have { specifier, version } shape + const allDeps = { + ...importer.dependencies, + ...importer.devDependencies, + }; + + for (const [pkgName, entry] of Object.entries(allDeps)) { + // Skip aliases: specifier starts with "npm:" (e.g. npm:@types/node@^22) + if (entry.specifier?.startsWith("npm:")) { + continue; + } + // Skip workspace/file links + if ( + entry.version?.startsWith("link:") || + entry.version?.startsWith("file:") + ) { + continue; + } + const version = stripPnpmPeerSuffix(entry.version); + if (version) { + versions.set(pkgName, version); + } + } + + return versions; + } + + // v5 single-project fallback — no `importers` key; deps are top-level + // plain string maps. Only applies to the root importer ("."). + if ( + importerKey === "." && + (lockfile.dependencies || lockfile.devDependencies) + ) { + const allDeps: Record = { + ...lockfile.dependencies, + ...lockfile.devDependencies, + }; + + for (const [pkgName, rawVersion] of Object.entries(allDeps)) { + // Skip aliases: specifier starts with "npm:" (e.g. npm:react@^18) + if (lockfile.specifiers?.[pkgName]?.startsWith("npm:")) { + continue; + } + // Skip workspace/file links + if (rawVersion.startsWith("link:") || rawVersion.startsWith("file:")) { + continue; + } + // Skip pnpm path-form entries like "/react/18.2.0" + if (rawVersion.startsWith("/")) { + continue; + } + const version = stripPnpmPeerSuffix(rawVersion); + if (version) { + versions.set(pkgName, version); + } + } + } + + return versions; +} + +/** + * Strips the peer-dependency suffix from a pnpm version string. + * + * **v6+** appends peer info in parentheses: + * `"4.1.0(@types/node@22.15.17)(esbuild@0.28.1)"` → `"4.1.0"` + * + * **v5** uses an underscore separator: + * `"1.2.3_react@16.8.0"` → `"1.2.3"` + * + * This function cuts at whichever separator appears first. This is safe + * because semantic version strings never contain `_` or `(`. + * + * @param version - The raw version string from a pnpm lockfile + * @returns The bare semver version, or `undefined` if the input is falsy + */ +function stripPnpmPeerSuffix(version: string | undefined): string | undefined { + if (!version) { + return undefined; + } + const parenIndex = version.indexOf("("); + const underscoreIndex = version.indexOf("_"); + + // Find the earliest separator, ignoring -1 (not found) + let cutAt = -1; + if (parenIndex !== -1 && underscoreIndex !== -1) { + cutAt = Math.min(parenIndex, underscoreIndex); + } else if (parenIndex !== -1) { + cutAt = parenIndex; + } else if (underscoreIndex !== -1) { + cutAt = underscoreIndex; + } + + return cutAt === -1 ? version : version.slice(0, cutAt); +} + +interface PnpmLockfile { + lockfileVersion?: string; + importers?: Record< + string, + { + dependencies?: Record; + devDependencies?: Record< + string, + { specifier?: string; version?: string } + >; + } + >; + /** v5 single-project: top-level deps as plain `name: version` strings */ + dependencies?: Record; + /** v5 single-project: top-level devDeps as plain `name: version` strings */ + devDependencies?: Record; + /** v5: maps package names to their original specifiers (e.g. `"^4.17.21"` or `"npm:react@^18"`) */ + specifiers?: Record; +} diff --git a/packages/workers-utils/src/package-resolution/lockfiles-resolution/resolve.ts b/packages/workers-utils/src/package-resolution/lockfiles-resolution/resolve.ts new file mode 100644 index 0000000000..33ddba2a53 --- /dev/null +++ b/packages/workers-utils/src/package-resolution/lockfiles-resolution/resolve.ts @@ -0,0 +1,115 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { parseBunLockfile } from "./bun"; +import { findLockfile } from "./discovery"; +import { parseNpmLockfile } from "./npm"; +import { parsePnpmLockfile } from "./pnpm"; +import { parseYarnLockfile } from "./yarn"; +import type { LockfileName } from "./discovery"; + +/** + * Memoization cache for parsed lockfile version maps. + * Keyed by `lockfilePath + "\0" + projectPath` so that different workspace + * packages sharing the same pnpm lockfile each get their own entry (pnpm + * lockfiles use per-importer dependency maps). The null byte separator + * cannot appear in file paths, so the composite key is unambiguous. + * Lockfiles are not expected to change during a single process invocation + * (e.g. a `wrangler deploy`), so a simple cache is sufficient. + */ +const cache = new Map>(); + +/** + * Resolves installed package versions from the nearest lockfile. + * + * Walks up the directory tree from `projectPath` to locate a lockfile + * (pnpm-lock.yaml, package-lock.json, yarn.lock, or bun.lock), parses it, + * and returns a `Map` for the project's + * top-level dependencies. + * + * When caching is enabled (the default), results are memoized per lockfile + * path and project path, so repeated per-package lookups within a single + * process (e.g. inside `collectPackageDependencies`) are O(1) after the + * initial parse. + * + * @param projectPath - Absolute path to the project directory + * @param opts - Options + * @param opts.cache - Whether to use the memoization cache (default: `true`). + * Set to `false` to always re-read and re-parse the lockfile. + * @param opts.stopAtProjectPath - If `true`, do not search for lockfiles + * above `projectPath`. Prevents a monorepo root + * lockfile from being used when only the + * project-local lockfile should be consulted. + * @returns A map of package names to their resolved versions, or + * `undefined` if no parseable lockfile is found + */ +export function getInstalledVersionsFromLockfile( + projectPath: string, + opts: { cache?: boolean; stopAtProjectPath?: boolean } = {} +): Map | undefined { + const found = findLockfile(projectPath, { + last: opts.stopAtProjectPath === true ? projectPath : undefined, + }); + if (!found) { + return undefined; + } + + const { lockfilePath, name } = found; + const useCache = opts.cache !== false; + const cacheKey = `${lockfilePath}\0${projectPath}`; + + if (useCache) { + const cached = cache.get(cacheKey); + if (cached) { + return cached; + } + + const versions = parseLockfile(lockfilePath, name, projectPath); + if (versions) { + cache.set(cacheKey, versions); + } + return versions; + } + + // Caching disabled — parse fresh, skip cache entirely + return parseLockfile(lockfilePath, name, projectPath); +} + +/** + * Reads and parses a lockfile, dispatching to the correct parser based on + * the lockfile name. + * + * @param lockfilePath - Absolute path to the lockfile + * @param name - The lockfile filename (determines which parser to use) + * @param projectPath - The project directory (used for pnpm importer key) + * @returns A map of package names to resolved versions, or `undefined` on + * parse failure + */ +function parseLockfile( + lockfilePath: string, + name: LockfileName, + projectPath: string +): Map | undefined { + try { + const content = readFileSync(lockfilePath, "utf-8"); + const lockfileDir = path.dirname(lockfilePath); + // Normalize to forward slashes, pnpm lockfiles always use POSIX + // separators for importer keys, but `path.relative` uses backslashes + // on Windows. + const importerKey = + path.relative(lockfileDir, projectPath).replaceAll("\\", "/") || "."; + + switch (name) { + case "package-lock.json": + return parseNpmLockfile(content); + case "pnpm-lock.yaml": + return parsePnpmLockfile(content, importerKey); + case "yarn.lock": + return parseYarnLockfile(content); + case "bun.lock": + return parseBunLockfile(content); + } + } catch { + // Parse failure → return undefined so the caller can fall back + return undefined; + } +} diff --git a/packages/workers-utils/src/package-resolution/lockfiles-resolution/yarn.ts b/packages/workers-utils/src/package-resolution/lockfiles-resolution/yarn.ts new file mode 100644 index 0000000000..8a10819dcc --- /dev/null +++ b/packages/workers-utils/src/package-resolution/lockfiles-resolution/yarn.ts @@ -0,0 +1,151 @@ +import { parse as yarnLockfileParse } from "@yarnpkg/lockfile"; +import { parse as parseYaml } from "yaml"; + +/** + * Parses a yarn.lock file (either classic v1 or Berry v2+) and extracts + * dependency versions. + * + * Format detection uses the `__metadata` key (present in Berry) or the + * `# yarn lockfile v1` header (classic). + * + * @param content - Raw content of the yarn.lock file + * @returns A map of package names to resolved versions + */ +export function parseYarnLockfile(content: string): Map { + // Berry v2+ lockfiles are valid YAML and contain a `__metadata` key. + // Classic v1 lockfiles start with a comment header and use a custom format. + if (content.includes("__metadata:")) { + return parseYarnBerryLockfile(content); + } + return parseYarnClassicLockfile(content); +} + +/** + * Parses a Yarn Berry (v2+) lockfile, which is valid YAML. + * + * Keys have the form `"name@npm:^1.0.0"` (or multiple comma-separated + * descriptors). Versions are in the `version` property. + * + * @param content - Raw YAML content + * @returns A map of package names to resolved versions + */ +function parseYarnBerryLockfile(content: string): Map { + const lockfile = parseYaml(content) as Record< + string, + { version?: string; resolution?: string } + >; + const versions = new Map(); + + for (const [key, entry] of Object.entries(lockfile)) { + if (key === "__metadata" || !entry?.version) { + continue; + } + + // Key format: "name@npm:^1.0.0" or "name@npm:^1.0.0, name@npm:^2.0.0" + // For scoped packages: "@scope/name@npm:^1.0.0" + // Take the first descriptor if there are multiple + const descriptor = key.split(", ")[0]; + const pkgName = extractYarnBerryPackageName(descriptor); + if (!pkgName) { + continue; + } + + // Alias detection: if the resolution field names a different package, + // skip this entry so it falls back to node_modules + if (entry.resolution) { + const resolvedName = extractYarnBerryPackageName(entry.resolution); + if (resolvedName && resolvedName !== pkgName) { + continue; + } + } + + // Only set the first occurrence — later entries with different ranges + // that resolve to the same package would overwrite otherwise + if (!versions.has(pkgName)) { + versions.set(pkgName, entry.version); + } + } + + return versions; +} + +/** + * Extracts the package name from a Yarn Berry descriptor or resolution string. + * + * Descriptor format: `"name@npm:^1.0.0"` or `"@scope/name@npm:^1.0.0"` + * Resolution format: `"name@npm:1.0.0"` or `"@scope/name@npm:1.0.0"` + * + * @param descriptor - A Yarn Berry descriptor string + * @returns The package name, or `undefined` if the format is unrecognised + */ +function extractYarnBerryPackageName(descriptor: string): string | undefined { + const npmIndex = descriptor.indexOf("@npm:"); + if (npmIndex === -1) { + return undefined; + } + // For "@scope/name@npm:..." npmIndex is after the scoped name + // For "name@npm:..." npmIndex is after the bare name + const name = descriptor.slice(0, npmIndex); + return name || undefined; +} + +/** + * Parses a Yarn Classic (v1) lockfile using the `@yarnpkg/lockfile` library. + * + * Keys have the form `"name@^1.0.0"` (or multiple comma-separated specifiers). + * For scoped packages: `"@scope/name@^1.0.0"`. + * + * @param content - Raw lockfile content (custom Yarn v1 format) + * @returns A map of package names to resolved versions + */ +function parseYarnClassicLockfile(content: string): Map { + const result = yarnLockfileParse(content); + const versions = new Map(); + + if (result.type !== "success") { + return versions; + } + + for (const [key, entry] of Object.entries(result.object)) { + if (!entry.version) { + continue; + } + + // Key format: "name@^1.0.0" or "@scope/name@^1.0.0" + // Multiple specifiers: "name@^1.0.0, name@^2.0.0" (but @yarnpkg/lockfile + // splits these into separate keys) + // + // Alias format: "alias-name@npm:real-name@^1.0.0" + if (key.includes("@npm:")) { + continue; + } + + const pkgName = extractYarnClassicPackageName(key); + if (pkgName && !versions.has(pkgName)) { + versions.set(pkgName, entry.version); + } + } + + return versions; +} + +/** + * Extracts the package name from a Yarn Classic lockfile key. + * + * Key format: `"name@^1.0.0"` or `"@scope/name@^1.0.0"` + * + * For scoped packages, the first `@` is part of the scope, so the version + * specifier starts at the *last* `@` sign. + * + * @param key - A Yarn Classic lockfile entry key + * @returns The package name, or `undefined` if the format is invalid + */ +function extractYarnClassicPackageName(key: string): string | undefined { + // Remove surrounding quotes if present + const cleaned = key.replace(/^"|"$/g, ""); + const lastAt = cleaned.lastIndexOf("@"); + if (lastAt <= 0) { + return undefined; + } + return cleaned.slice(0, lastAt); +} diff --git a/packages/workers-utils/src/package-resolution.ts b/packages/workers-utils/src/package-resolution/node-modules.ts similarity index 69% rename from packages/workers-utils/src/package-resolution.ts rename to packages/workers-utils/src/package-resolution/node-modules.ts index 7e1ad78752..36aa22842a 100644 --- a/packages/workers-utils/src/package-resolution.ts +++ b/packages/workers-utils/src/package-resolution/node-modules.ts @@ -1,6 +1,6 @@ -import { statSync } from "node:fs"; import path from "node:path"; -import { parsePackageJSON, readFileSync } from "./parse"; +import * as find from "empathic/find"; +import { parsePackageJSON, readFileSync } from "../parse"; /** * Resolves the filesystem path for an installed npm package. @@ -53,8 +53,11 @@ export function isPackageInstalled( } /** - * Gets the exact version of an npm package installed in a project by resolving - * it from node_modules and reading its package.json. + * Resolves a package version by reading its package.json from node_modules. + * + * This is the original resolution strategy, preserved as a fallback for + * packages that cannot be resolved from lockfiles (aliases, binary lockfiles, + * missing lockfiles, etc.). * * @param packageName - The name of the target package * @param projectPath - The path of the project to check @@ -62,7 +65,7 @@ export function isPackageInstalled( * @param opts.stopAtProjectPath - If `true`, stop walking up at the project's path * @returns The installed version string, or `undefined` if the package is not installed */ -export function getInstalledPackageVersion( +export function getInstalledPackageVersionFromNodeModules( packageName: string, projectPath: string, opts: { @@ -75,8 +78,10 @@ export function getInstalledPackageVersion( return undefined; } - const lastDir = opts.stopAtProjectPath === true ? projectPath : undefined; - const packageJsonPath = findFileUp("package.json", packagePath, lastDir); + const packageJsonPath = find.file("package.json", { + cwd: packagePath, + last: opts.stopAtProjectPath === true ? projectPath : undefined, + }); if (!packageJsonPath) { return undefined; @@ -105,42 +110,3 @@ export function getInstalledPackageVersion( return packageJson.version; } catch {} } - -/** - * Walks up from `startDir` looking for a file named `name`. - * Stops at `lastDir` (inclusive) if provided, otherwise walks to the filesystem root. - * - * @param name - The filename to search for - * @param startDir - The directory to start searching from - * @param lastDir - If provided, stop searching after reaching this directory - * @returns The full path to the found file, or `undefined` - */ -function findFileUp( - name: string, - startDir: string, - lastDir?: string -): string | undefined { - let dir = startDir; - const root = path.parse(dir).root; - - while (true) { - const candidate = path.join(dir, name); - try { - if (statSync(candidate).isFile()) { - return candidate; - } - } catch {} - - if (lastDir !== undefined && dir === lastDir) { - break; - } - - const parent = path.dirname(dir); - if (parent === dir || dir === root) { - break; - } - dir = parent; - } - - return undefined; -} diff --git a/packages/workers-utils/src/package-resolution/package-resolution.ts b/packages/workers-utils/src/package-resolution/package-resolution.ts new file mode 100644 index 0000000000..e4d182ed13 --- /dev/null +++ b/packages/workers-utils/src/package-resolution/package-resolution.ts @@ -0,0 +1,50 @@ +import { getInstalledVersionsFromLockfile } from "./lockfiles-resolution"; +import { getInstalledPackageVersionFromNodeModules } from "./node-modules"; + +/** + * Gets the exact version of an npm package installed in a project. + * + * Resolution strategy (first match wins): + * 1. **Lockfile** — looks up the version in the nearest parseable lockfile + * (pnpm-lock.yaml, package-lock.json, yarn.lock, or bun.lock). The + * lockfile is parsed once and memoized (unless caching is disabled), so + * repeated per-package calls within the same process are O(1). + * Aliases (npm: protocol, vite+, etc.) are intentionally excluded from + * lockfile results so they fall through to the node_modules path, which + * handles vite's `bundledVersions` convention correctly. + * 2. **node_modules** — resolves the package via `require.resolve`, reads + * its `package.json`, and returns `version` (with special-case handling + * for vite+ `bundledVersions`). + * + * @param packageName - The name of the target package + * @param projectPath - The path of the project to check + * @param opts - Options + * @param opts.stopAtProjectPath - If `true`, stop walking up at the project's path + * @param opts.cache - Whether to use the lockfile memoization cache (default: `true`) + * @returns The installed version string, or `undefined` if the package is not installed + */ +export function getInstalledPackageVersion( + packageName: string, + projectPath: string, + opts: { + stopAtProjectPath?: boolean; + cache?: boolean; + } = {} +): string | undefined { + // Fast path: consult the lockfile first (memoized by default, O(1) per lookup) + const lockfileVersions = getInstalledVersionsFromLockfile(projectPath, { + cache: opts.cache, + stopAtProjectPath: opts.stopAtProjectPath, + }); + const lockfileVersion = lockfileVersions?.get(packageName); + if (lockfileVersion) { + return lockfileVersion; + } + + // Fallback: resolve from node_modules (handles aliases, bundledVersions, etc.) + return getInstalledPackageVersionFromNodeModules( + packageName, + projectPath, + opts + ); +} diff --git a/packages/workers-utils/tests/package-resolution/lockfile-resolution/bun.test.ts b/packages/workers-utils/tests/package-resolution/lockfile-resolution/bun.test.ts new file mode 100644 index 0000000000..9b31c78b6c --- /dev/null +++ b/packages/workers-utils/tests/package-resolution/lockfile-resolution/bun.test.ts @@ -0,0 +1,153 @@ +import assert from "node:assert"; +import * as fs from "node:fs"; +import { describe, it } from "vitest"; +import { runInTempDir } from "../../../src/test-helpers"; +import { resolveFromLockFile } from "./share"; + +describe("lockfile resolution — bun.lock", () => { + runInTempDir(); + + it("parses JSONC format", ({ expect }) => { + fs.writeFileSync( + "bun.lock", + JSON.stringify({ + lockfileVersion: 0, + workspaces: { + "": { + name: "test-project", + dependencies: { lodash: "^4.17.21" }, + }, + }, + packages: { + lodash: ["lodash@4.17.21", {}], + express: ["express@4.18.2", {}], + }, + }) + ); + + const versions = resolveFromLockFile(process.cwd()); + assert(versions); + expect(versions.get("lodash")).toBe("4.17.21"); + expect(versions.get("express")).toBe("4.18.2"); + }); + + it("handles scoped packages", ({ expect }) => { + fs.writeFileSync( + "bun.lock", + JSON.stringify({ + lockfileVersion: 0, + packages: { + "@types/node": ["@types/node@22.15.17", {}], + }, + }) + ); + + const versions = resolveFromLockFile(process.cwd()); + assert(versions); + expect(versions.get("@types/node")).toBe("22.15.17"); + }); + + it("skips aliases (key differs from resolved name)", ({ expect }) => { + fs.writeFileSync( + "bun.lock", + JSON.stringify({ + lockfileVersion: 0, + packages: { + ts552: ["typescript@5.5.2", {}], + lodash: ["lodash@4.17.21", {}], + }, + }) + ); + + const versions = resolveFromLockFile(process.cwd()); + assert(versions); + expect(versions.has("ts552")).toBe(false); + expect(versions.get("lodash")).toBe("4.17.21"); + }); + + it("handles JSONC with trailing commas", ({ expect }) => { + // bun.lock is JSONC which may have trailing commas + fs.writeFileSync( + "bun.lock", + `{ + "lockfileVersion": 0, + "packages": { + "lodash": ["lodash@4.17.21", {},], + }, + }` + ); + + const versions = resolveFromLockFile(process.cwd()); + assert(versions); + expect(versions.get("lodash")).toBe("4.17.21"); + }); + + it("skips non-array and empty-array entries", ({ expect }) => { + fs.writeFileSync( + "bun.lock", + JSON.stringify({ + lockfileVersion: 0, + packages: { + "bad-string": "not-an-array", + "bad-empty": [], + lodash: ["lodash@4.17.21", {}], + }, + }) + ); + + const versions = resolveFromLockFile(process.cwd()); + assert(versions); + expect(versions.has("bad-string")).toBe(false); + expect(versions.has("bad-empty")).toBe(false); + expect(versions.get("lodash")).toBe("4.17.21"); + }); + + it("skips entries with non-string first element", ({ expect }) => { + fs.writeFileSync( + "bun.lock", + JSON.stringify({ + lockfileVersion: 0, + packages: { + "bad-number": [42, {}], + lodash: ["lodash@4.17.21", {}], + }, + }) + ); + + const versions = resolveFromLockFile(process.cwd()); + assert(versions); + expect(versions.has("bad-number")).toBe(false); + expect(versions.get("lodash")).toBe("4.17.21"); + }); + + it("skips entries without a parseable version", ({ expect }) => { + fs.writeFileSync( + "bun.lock", + JSON.stringify({ + lockfileVersion: 0, + packages: { + "no-at-sign": ["justastring", {}], + lodash: ["lodash@4.17.21", {}], + }, + }) + ); + + const versions = resolveFromLockFile(process.cwd()); + assert(versions); + expect(versions.has("no-at-sign")).toBe(false); + expect(versions.get("lodash")).toBe("4.17.21"); + }); + + it("returns an empty map when packages key is missing", ({ expect }) => { + fs.writeFileSync( + "bun.lock", + JSON.stringify({ + lockfileVersion: 0, + }) + ); + + const versions = resolveFromLockFile(process.cwd()); + assert(versions); + expect(versions.size).toBe(0); + }); +}); diff --git a/packages/workers-utils/tests/package-resolution/lockfile-resolution/npm.test.ts b/packages/workers-utils/tests/package-resolution/lockfile-resolution/npm.test.ts new file mode 100644 index 0000000000..dce0ba9065 --- /dev/null +++ b/packages/workers-utils/tests/package-resolution/lockfile-resolution/npm.test.ts @@ -0,0 +1,195 @@ +import assert from "node:assert"; +import * as fs from "node:fs"; +import { describe, it } from "vitest"; +import { runInTempDir } from "../../../src/test-helpers"; +import { resolveFromLockFile } from "./share"; + +describe("lockfile resolution — npm package-lock.json", () => { + runInTempDir(); + + it("parses lockfileVersion 3 (v2/v3 format)", ({ expect }) => { + fs.writeFileSync( + "package-lock.json", + JSON.stringify({ + name: "test-project", + version: "1.0.0", + lockfileVersion: 3, + packages: { + "": { + name: "test-project", + version: "1.0.0", + dependencies: { lodash: "^4.17.21" }, + }, + "node_modules/lodash": { + version: "4.17.21", + resolved: "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + }, + "node_modules/express": { + version: "4.18.2", + resolved: "https://registry.npmjs.org/express/-/express-4.18.2.tgz", + }, + }, + }) + ); + + const versions = resolveFromLockFile(process.cwd()); + assert(versions); + expect(versions.get("lodash")).toBe("4.17.21"); + expect(versions.get("express")).toBe("4.18.2"); + }); + + it("parses lockfileVersion 1 format", ({ expect }) => { + fs.writeFileSync( + "package-lock.json", + JSON.stringify({ + name: "test-project", + version: "1.0.0", + lockfileVersion: 1, + dependencies: { + lodash: { + version: "4.17.21", + resolved: "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + }, + }, + }) + ); + + const versions = resolveFromLockFile(process.cwd()); + assert(versions); + expect(versions.get("lodash")).toBe("4.17.21"); + }); + + it("skips nested node_modules entries (transitive deps)", ({ expect }) => { + fs.writeFileSync( + "package-lock.json", + JSON.stringify({ + lockfileVersion: 3, + packages: { + "": { name: "test", version: "1.0.0" }, + "node_modules/foo": { version: "1.0.0" }, + "node_modules/foo/node_modules/bar": { version: "2.0.0" }, + }, + }) + ); + + const versions = resolveFromLockFile(process.cwd()); + assert(versions); + expect(versions.get("foo")).toBe("1.0.0"); + expect(versions.has("bar")).toBe(false); + }); + + it("skips npm aliases in v2/v3 format", ({ expect }) => { + fs.writeFileSync( + "package-lock.json", + JSON.stringify({ + lockfileVersion: 3, + packages: { + "": { name: "test", version: "1.0.0" }, + "node_modules/my-react": { + name: "react", + version: "18.2.0", + }, + "node_modules/lodash": { + version: "4.17.21", + }, + }, + }) + ); + + const versions = resolveFromLockFile(process.cwd()); + assert(versions); + expect(versions.has("my-react")).toBe(false); + expect(versions.get("lodash")).toBe("4.17.21"); + }); + + it("skips npm aliases in v1 format", ({ expect }) => { + fs.writeFileSync( + "package-lock.json", + JSON.stringify({ + lockfileVersion: 1, + dependencies: { + "my-react": { + version: "npm:react@18.2.0", + }, + lodash: { + version: "4.17.21", + }, + }, + }) + ); + + const versions = resolveFromLockFile(process.cwd()); + assert(versions); + expect(versions.has("my-react")).toBe(false); + expect(versions.get("lodash")).toBe("4.17.21"); + }); + + it("handles scoped packages", ({ expect }) => { + fs.writeFileSync( + "package-lock.json", + JSON.stringify({ + lockfileVersion: 3, + packages: { + "": { name: "test", version: "1.0.0" }, + "node_modules/@types/node": { + version: "22.15.17", + }, + "node_modules/@cloudflare/workers-types": { + version: "5.20260710.1", + }, + }, + }) + ); + + const versions = resolveFromLockFile(process.cwd()); + assert(versions); + expect(versions.get("@types/node")).toBe("22.15.17"); + expect(versions.get("@cloudflare/workers-types")).toBe("5.20260710.1"); + }); + + it("prefers packages over dependencies in lockfileVersion 2", ({ + expect, + }) => { + // lockfileVersion 2 has both `packages` (new format) and `dependencies` + // (legacy fallback). The parser should use `packages` and ignore the + // legacy section, and should not emit the root `""` entry. + fs.writeFileSync( + "package-lock.json", + JSON.stringify({ + name: "test-project", + version: "1.0.0", + lockfileVersion: 2, + packages: { + "": { + name: "test-project", + version: "1.0.0", + dependencies: { lodash: "^4.17.21" }, + }, + "node_modules/lodash": { + version: "4.17.21", + }, + }, + dependencies: { + lodash: { + version: "4.17.20", + }, + }, + }) + ); + + const versions = resolveFromLockFile(process.cwd()); + assert(versions); + // packages wins over legacy dependencies + expect(versions.get("lodash")).toBe("4.17.21"); + // root entry "" is not emitted as a package + expect(versions.has("")).toBe(false); + expect(versions.has("test-project")).toBe(false); + }); + + it("returns undefined for malformed JSON", ({ expect }) => { + fs.writeFileSync("package-lock.json", "{ this is not valid json !!!"); + + const versions = resolveFromLockFile(process.cwd()); + expect(versions).toBeUndefined(); + }); +}); diff --git a/packages/workers-utils/tests/package-resolution/lockfile-resolution/pnpm.test.ts b/packages/workers-utils/tests/package-resolution/lockfile-resolution/pnpm.test.ts new file mode 100644 index 0000000000..f7aa171394 --- /dev/null +++ b/packages/workers-utils/tests/package-resolution/lockfile-resolution/pnpm.test.ts @@ -0,0 +1,348 @@ +import assert from "node:assert"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { describe, it } from "vitest"; +import { runInTempDir } from "../../../src/test-helpers"; +import { resolveFromLockFile } from "./share"; + +describe("lockfile resolution — pnpm pnpm-lock.yaml", () => { + runInTempDir(); + + it("parses v9 lockfile for the root importer", ({ expect }) => { + fs.writeFileSync( + "pnpm-lock.yaml", + [ + "lockfileVersion: '9.0'", + "", + "importers:", + " .:", + " dependencies:", + " lodash:", + " specifier: ^4.17.21", + " version: 4.17.21", + " devDependencies:", + " typescript:", + " specifier: ^5.3.0", + " version: 5.8.3", + ].join("\n") + ); + + const versions = resolveFromLockFile(process.cwd()); + assert(versions); + expect(versions.get("lodash")).toBe("4.17.21"); + expect(versions.get("typescript")).toBe("5.8.3"); + }); + + it("parses v6 lockfile", ({ expect }) => { + fs.writeFileSync( + "pnpm-lock.yaml", + [ + "lockfileVersion: '6.0'", + "", + "importers:", + " .:", + " dependencies:", + " express:", + " specifier: ^4.18.2", + " version: 4.18.2", + ].join("\n") + ); + + const versions = resolveFromLockFile(process.cwd()); + assert(versions); + expect(versions.get("express")).toBe("4.18.2"); + }); + + it("strips peer dependency suffixes", ({ expect }) => { + fs.writeFileSync( + "pnpm-lock.yaml", + [ + "lockfileVersion: '9.0'", + "", + "importers:", + " .:", + " dependencies:", + " vitest:", + " specifier: ^4.1.0", + " version: 4.1.0(@types/node@22.15.17)(esbuild@0.28.1)", + " simple-pkg:", + " specifier: ^1.0.0", + " version: 1.0.0", + ].join("\n") + ); + + const versions = resolveFromLockFile(process.cwd()); + assert(versions); + expect(versions.get("vitest")).toBe("4.1.0"); + expect(versions.get("simple-pkg")).toBe("1.0.0"); + }); + + it("skips npm aliases", ({ expect }) => { + fs.writeFileSync( + "pnpm-lock.yaml", + [ + "lockfileVersion: '9.0'", + "", + "importers:", + " .:", + " devDependencies:", + " node-types-alias:", + " specifier: 'npm:@types/node@^22.14.0'", + " version: '@types/node@22.15.17'", + " real-pkg:", + " specifier: ^1.0.0", + " version: 1.0.0", + ].join("\n") + ); + + const versions = resolveFromLockFile(process.cwd()); + assert(versions); + expect(versions.has("node-types-alias")).toBe(false); + expect(versions.get("real-pkg")).toBe("1.0.0"); + }); + + it("skips link: and file: entries", ({ expect }) => { + fs.writeFileSync( + "pnpm-lock.yaml", + [ + "lockfileVersion: '9.0'", + "", + "importers:", + " .:", + " dependencies:", + " my-lib:", + " specifier: workspace:*", + " version: link:../my-lib", + " local-pkg:", + " specifier: file:../local-pkg", + " version: 'file:../local-pkg'", + " real-pkg:", + " specifier: ^1.0.0", + " version: 1.0.0", + ].join("\n") + ); + + const versions = resolveFromLockFile(process.cwd()); + assert(versions); + expect(versions.has("my-lib")).toBe(false); + expect(versions.has("local-pkg")).toBe(false); + expect(versions.get("real-pkg")).toBe("1.0.0"); + }); + + it("resolves a sub-package importer in a monorepo", ({ expect }) => { + // Lockfile is at the monorepo root + const subPkgDir = path.join(process.cwd(), "packages", "my-app"); + fs.mkdirSync(subPkgDir, { recursive: true }); + + fs.writeFileSync( + "pnpm-lock.yaml", + [ + "lockfileVersion: '9.0'", + "", + "importers:", + " .:", + " dependencies:", + " root-only-dep:", + " specifier: ^1.0.0", + " version: 1.0.0", + " packages/my-app:", + " dependencies:", + " sub-pkg-dep:", + " specifier: ^2.0.0", + " version: 2.5.0", + ].join("\n") + ); + + const versions = resolveFromLockFile(subPkgDir); + assert(versions); + expect(versions.get("sub-pkg-dep")).toBe("2.5.0"); + expect(versions.has("root-only-dep")).toBe(false); + }); + + it("merges dependencies and devDependencies", ({ expect }) => { + fs.writeFileSync( + "pnpm-lock.yaml", + [ + "lockfileVersion: '9.0'", + "", + "importers:", + " .:", + " dependencies:", + " lodash:", + " specifier: ^4.17.21", + " version: 4.17.21", + " devDependencies:", + " typescript:", + " specifier: ^5.3.0", + " version: 5.8.3", + " vitest:", + " specifier: ^4.1.0", + " version: 4.1.0", + ].join("\n") + ); + + const versions = resolveFromLockFile(process.cwd()); + assert(versions); + expect(versions.get("lodash")).toBe("4.17.21"); + expect(versions.get("typescript")).toBe("5.8.3"); + expect(versions.get("vitest")).toBe("4.1.0"); + }); + + it("returns an empty map for a missing importer key", ({ expect }) => { + fs.writeFileSync( + "pnpm-lock.yaml", + [ + "lockfileVersion: '9.0'", + "", + "importers:", + " .:", + " dependencies:", + " lodash:", + " specifier: ^4.17.21", + " version: 4.17.21", + ].join("\n") + ); + + // Request an importer that doesn't exist + const subDir = path.join(process.cwd(), "packages", "nonexistent"); + fs.mkdirSync(subDir, { recursive: true }); + const versions = resolveFromLockFile(subDir); + assert(versions); + expect(versions.size).toBe(0); + }); + + it("parses v5 single-project lockfile (no importers)", ({ expect }) => { + fs.writeFileSync( + "pnpm-lock.yaml", + [ + "lockfileVersion: 5.4", + "", + "specifiers:", + " lodash: ^4.17.21", + " typescript: ^4.9.0", + "", + "dependencies:", + " lodash: 4.17.21", + "", + "devDependencies:", + " typescript: 4.9.5", + ].join("\n") + ); + + const versions = resolveFromLockFile(process.cwd()); + assert(versions); + expect(versions.get("lodash")).toBe("4.17.21"); + expect(versions.get("typescript")).toBe("4.9.5"); + }); + + it("strips v5 underscore peer-dependency suffixes", ({ expect }) => { + fs.writeFileSync( + "pnpm-lock.yaml", + [ + "lockfileVersion: 5.4", + "", + "specifiers:", + " react-dom: ^16.8.0", + "", + "dependencies:", + " react-dom: 16.14.0_react@16.14.0", + ].join("\n") + ); + + const versions = resolveFromLockFile(process.cwd()); + assert(versions); + expect(versions.get("react-dom")).toBe("16.14.0"); + }); + + it("skips npm: aliases in v5 via specifiers map", ({ expect }) => { + fs.writeFileSync( + "pnpm-lock.yaml", + [ + "lockfileVersion: 5.4", + "", + "specifiers:", + " node-types: npm:@types/node@^16", + " lodash: ^4.17.21", + "", + "dependencies:", + " node-types: /@types/node/16.18.0", + " lodash: 4.17.21", + ].join("\n") + ); + + const versions = resolveFromLockFile(process.cwd()); + assert(versions); + // node-types is an alias → skipped via specifiers check + expect(versions.has("node-types")).toBe(false); + expect(versions.get("lodash")).toBe("4.17.21"); + }); + + it("skips link: and file: entries in v5", ({ expect }) => { + fs.writeFileSync( + "pnpm-lock.yaml", + [ + "lockfileVersion: 5.4", + "", + "specifiers:", + " my-lib: workspace:*", + " lodash: ^4.17.21", + "", + "dependencies:", + " my-lib: link:../my-lib", + " lodash: 4.17.21", + ].join("\n") + ); + + const versions = resolveFromLockFile(process.cwd()); + assert(versions); + expect(versions.has("my-lib")).toBe(false); + expect(versions.get("lodash")).toBe("4.17.21"); + }); + + it("skips path-form entries (e.g. /pkg/version) in v5", ({ expect }) => { + fs.writeFileSync( + "pnpm-lock.yaml", + [ + "lockfileVersion: 5.4", + "", + "specifiers:", + " react: ^16.8.0", + " lodash: ^4.17.21", + "", + "dependencies:", + " react: /react/16.14.0", + " lodash: 4.17.21", + ].join("\n") + ); + + const versions = resolveFromLockFile(process.cwd()); + assert(versions); + // path-form entries are skipped + expect(versions.has("react")).toBe(false); + expect(versions.get("lodash")).toBe("4.17.21"); + }); + + it("v5 fallback only applies to root importer", ({ expect }) => { + // v5 top-level deps should only be used when importerKey is "." + const subDir = path.join(process.cwd(), "packages", "sub"); + fs.mkdirSync(subDir, { recursive: true }); + + fs.writeFileSync( + "pnpm-lock.yaml", + [ + "lockfileVersion: 5.4", + "", + "specifiers:", + " lodash: ^4.17.21", + "", + "dependencies:", + " lodash: 4.17.21", + ].join("\n") + ); + + // Requesting from a sub-package should not match v5 top-level deps + const versions = resolveFromLockFile(subDir); + assert(versions); + expect(versions.size).toBe(0); + }); +}); diff --git a/packages/workers-utils/tests/package-resolution/lockfile-resolution/resolve.test.ts b/packages/workers-utils/tests/package-resolution/lockfile-resolution/resolve.test.ts new file mode 100644 index 0000000000..9c8c9117ec --- /dev/null +++ b/packages/workers-utils/tests/package-resolution/lockfile-resolution/resolve.test.ts @@ -0,0 +1,219 @@ +import assert from "node:assert"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { describe, it } from "vitest"; +import { getInstalledVersionsFromLockfile } from "../../../src/package-resolution/lockfiles-resolution"; +import { runInTempDir } from "../../../src/test-helpers"; +import { resolveFromLockFile } from "./share"; + +describe("lockfile resolution — generic", () => { + runInTempDir(); + + it("returns undefined when no lockfile exists", ({ expect }) => { + const result = resolveFromLockFile(process.cwd()); + expect(result).toBeUndefined(); + }); + + // ----------------------------------------------------------------------- + // Lockfile discovery (walking up directories) + // ----------------------------------------------------------------------- + + describe("lockfile discovery", () => { + it("walks up to find a lockfile in a parent directory", ({ expect }) => { + fs.writeFileSync( + "package-lock.json", + JSON.stringify({ + lockfileVersion: 3, + packages: { + "": { name: "root", version: "1.0.0" }, + "node_modules/lodash": { version: "4.17.21" }, + }, + }) + ); + + const subDir = path.join(process.cwd(), "packages", "my-app"); + fs.mkdirSync(subDir, { recursive: true }); + + const versions = resolveFromLockFile(subDir); + assert(versions); + expect(versions.get("lodash")).toBe("4.17.21"); + }); + }); + + // ----------------------------------------------------------------------- + // Lockfile priority + // ----------------------------------------------------------------------- + + describe("lockfile priority", () => { + it("prefers pnpm-lock.yaml over package-lock.json in the same directory", ({ + expect, + }) => { + fs.writeFileSync( + "pnpm-lock.yaml", + [ + "lockfileVersion: '9.0'", + "", + "importers:", + " .:", + " dependencies:", + " lodash:", + " specifier: ^4.17.21", + " version: 4.17.21", + ].join("\n") + ); + fs.writeFileSync( + "package-lock.json", + JSON.stringify({ + lockfileVersion: 3, + packages: { + "": { name: "test", version: "1.0.0" }, + "node_modules/lodash": { version: "4.17.20" }, + }, + }) + ); + + const versions = resolveFromLockFile(process.cwd()); + assert(versions); + // pnpm-lock.yaml comes first in LOCKFILE_NAMES + expect(versions.get("lodash")).toBe("4.17.21"); + }); + + it("prefers a nearer lockfile over a higher-priority one in an ancestor", ({ + expect, + }) => { + // Root has a pnpm-lock.yaml (higher priority name) + fs.writeFileSync( + "pnpm-lock.yaml", + [ + "lockfileVersion: '9.0'", + "", + "importers:", + " .:", + " dependencies:", + " lodash:", + " specifier: ^4.17.21", + " version: 4.17.21", + ].join("\n") + ); + + // Sub-project has a package-lock.json (lower priority name, but nearer) + const subDir = path.join(process.cwd(), "sub-project"); + fs.mkdirSync(subDir, { recursive: true }); + fs.writeFileSync( + path.join(subDir, "package-lock.json"), + JSON.stringify({ + lockfileVersion: 3, + packages: { + "": { name: "sub", version: "1.0.0" }, + "node_modules/express": { version: "4.18.2" }, + }, + }) + ); + + const versions = resolveFromLockFile(subDir); + assert(versions); + // The nearer package-lock.json should win over the ancestor pnpm-lock.yaml + expect(versions.get("express")).toBe("4.18.2"); + expect(versions.has("lodash")).toBe(false); + }); + }); + + // ----------------------------------------------------------------------- + // Error handling + // ----------------------------------------------------------------------- + + describe("error handling", () => { + it("returns undefined when only bun.lockb (binary) is present", ({ + expect, + }) => { + // bun.lockb is a binary lockfile and cannot be parsed — it is + // intentionally excluded from LOCKFILE_NAMES + fs.writeFileSync("bun.lockb", Buffer.from([0x00, 0x01, 0x02])); + + const versions = resolveFromLockFile(process.cwd()); + expect(versions).toBeUndefined(); + }); + + it("returns undefined for a malformed pnpm-lock.yaml", ({ expect }) => { + fs.writeFileSync( + "pnpm-lock.yaml", + "this: is: not: [valid yaml: because: {{" + ); + + const versions = resolveFromLockFile(process.cwd()); + expect(versions).toBeUndefined(); + }); + }); + + // ----------------------------------------------------------------------- + // Memoization + // ----------------------------------------------------------------------- + + describe("memoization", () => { + it("returns the same map instance for repeated calls (cache hit)", ({ + expect, + }) => { + fs.writeFileSync( + "package-lock.json", + JSON.stringify({ + lockfileVersion: 3, + packages: { + "": { name: "test", version: "1.0.0" }, + "node_modules/lodash": { version: "4.17.21" }, + }, + }) + ); + + const first = getInstalledVersionsFromLockfile(process.cwd()); + const second = getInstalledVersionsFromLockfile(process.cwd()); + expect(first).toBe(second); + }); + + it("returns distinct results for different projectPaths in a pnpm monorepo", ({ + expect, + }) => { + // Simulate a pnpm monorepo with two workspace packages that have + // different dependency sets under the same lockfile. + fs.writeFileSync( + "pnpm-lock.yaml", + [ + "lockfileVersion: '9.0'", + "", + "importers:", + " packages/app-a:", + " dependencies:", + " lodash:", + " specifier: ^4.17.21", + " version: 4.17.21", + " packages/app-b:", + " dependencies:", + " express:", + " specifier: ^4.18.0", + " version: 4.18.2", + ].join("\n") + ); + + const appADir = path.join(process.cwd(), "packages", "app-a"); + const appBDir = path.join(process.cwd(), "packages", "app-b"); + fs.mkdirSync(appADir, { recursive: true }); + fs.mkdirSync(appBDir, { recursive: true }); + + const versionsA = getInstalledVersionsFromLockfile(appADir); + const versionsB = getInstalledVersionsFromLockfile(appBDir); + + assert(versionsA); + assert(versionsB); + + // app-a should see lodash, not express + expect(versionsA.get("lodash")).toBe("4.17.21"); + expect(versionsA.has("express")).toBe(false); + + // app-b should see express, not lodash + expect(versionsB.get("express")).toBe("4.18.2"); + expect(versionsB.has("lodash")).toBe(false); + + // The two maps must be distinct objects + expect(versionsA).not.toBe(versionsB); + }); + }); +}); diff --git a/packages/workers-utils/tests/package-resolution/lockfile-resolution/share.ts b/packages/workers-utils/tests/package-resolution/lockfile-resolution/share.ts new file mode 100644 index 0000000000..8543b4b990 --- /dev/null +++ b/packages/workers-utils/tests/package-resolution/lockfile-resolution/share.ts @@ -0,0 +1,12 @@ +import { getInstalledVersionsFromLockfile } from "../../../src/package-resolution/lockfiles-resolution"; + +/** + * Convenience wrapper that calls {@link getInstalledVersionsFromLockfile} + * with caching disabled, so each test gets a fresh parse. + * + * @param projectPath - Absolute path to the project directory + * @returns A map of package names to resolved versions, or `undefined` + */ +export function resolveFromLockFile(projectPath: string) { + return getInstalledVersionsFromLockfile(projectPath, { cache: false }); +} diff --git a/packages/workers-utils/tests/package-resolution/lockfile-resolution/yarn.test.ts b/packages/workers-utils/tests/package-resolution/lockfile-resolution/yarn.test.ts new file mode 100644 index 0000000000..7c5ce61453 --- /dev/null +++ b/packages/workers-utils/tests/package-resolution/lockfile-resolution/yarn.test.ts @@ -0,0 +1,235 @@ +import assert from "node:assert"; +import * as fs from "node:fs"; +import { describe, it } from "vitest"; +import { runInTempDir } from "../../../src/test-helpers"; +import { resolveFromLockFile } from "./share"; + +// ----------------------------------------------------------------------- +// Yarn Berry (v2+) +// ----------------------------------------------------------------------- + +describe("lockfile resolution — yarn.lock (Berry v2+)", () => { + runInTempDir(); + + it("parses Berry format with __metadata", ({ expect }) => { + fs.writeFileSync( + "yarn.lock", + [ + "__metadata:", + " version: 8", + " cacheKey: 10c0", + "", + '"lodash@npm:^4.17.21":', + " version: 4.17.21", + ' resolution: "lodash@npm:4.17.21"', + " languageName: node", + " linkType: hard", + "", + '"express@npm:^4.18.0":', + " version: 4.18.2", + ' resolution: "express@npm:4.18.2"', + " languageName: node", + " linkType: hard", + ].join("\n") + ); + + const versions = resolveFromLockFile(process.cwd()); + assert(versions); + expect(versions.get("lodash")).toBe("4.17.21"); + expect(versions.get("express")).toBe("4.18.2"); + }); + + it("handles scoped packages", ({ expect }) => { + fs.writeFileSync( + "yarn.lock", + [ + "__metadata:", + " version: 8", + "", + '"@types/node@npm:^22.0.0":', + " version: 22.15.17", + ' resolution: "@types/node@npm:22.15.17"', + " languageName: node", + " linkType: hard", + ].join("\n") + ); + + const versions = resolveFromLockFile(process.cwd()); + assert(versions); + expect(versions.get("@types/node")).toBe("22.15.17"); + }); + + it("skips aliases (resolution package name differs)", ({ expect }) => { + fs.writeFileSync( + "yarn.lock", + [ + "__metadata:", + " version: 8", + "", + '"my-react@npm:react@^18":', + " version: 18.2.0", + ' resolution: "react@npm:18.2.0"', + " languageName: node", + " linkType: hard", + "", + '"lodash@npm:^4.17.21":', + " version: 4.17.21", + ' resolution: "lodash@npm:4.17.21"', + " languageName: node", + " linkType: hard", + ].join("\n") + ); + + const versions = resolveFromLockFile(process.cwd()); + assert(versions); + expect(versions.has("my-react")).toBe(false); + expect(versions.get("lodash")).toBe("4.17.21"); + }); + + it("handles multiple descriptors resolving to the same package", ({ + expect, + }) => { + fs.writeFileSync( + "yarn.lock", + [ + "__metadata:", + " version: 8", + "", + '"lodash@npm:^4.17.0, lodash@npm:^4.17.21":', + " version: 4.17.21", + ' resolution: "lodash@npm:4.17.21"', + " languageName: node", + " linkType: hard", + ].join("\n") + ); + + const versions = resolveFromLockFile(process.cwd()); + assert(versions); + expect(versions.get("lodash")).toBe("4.17.21"); + }); + + it("skips entries without a version field", ({ expect }) => { + fs.writeFileSync( + "yarn.lock", + [ + "__metadata:", + " version: 8", + "", + '"no-version-pkg@npm:^1.0.0":', + ' resolution: "no-version-pkg@npm:1.0.0"', + " languageName: node", + " linkType: hard", + "", + '"lodash@npm:^4.17.21":', + " version: 4.17.21", + ' resolution: "lodash@npm:4.17.21"', + " languageName: node", + " linkType: hard", + ].join("\n") + ); + + const versions = resolveFromLockFile(process.cwd()); + assert(versions); + expect(versions.has("no-version-pkg")).toBe(false); + expect(versions.get("lodash")).toBe("4.17.21"); + }); +}); + +// ----------------------------------------------------------------------- +// Yarn Classic (v1) +// ----------------------------------------------------------------------- + +describe("lockfile resolution — yarn.lock (Classic v1)", () => { + runInTempDir(); + + it("parses classic v1 format", ({ expect }) => { + fs.writeFileSync( + "yarn.lock", + [ + "# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.", + "# yarn lockfile v1", + "", + "", + "lodash@^4.17.21:", + ' version "4.17.21"', + ' resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#..."', + " integrity sha512-...", + "", + "express@^4.18.2:", + ' version "4.18.2"', + ' resolved "https://registry.yarnpkg.com/express/-/express-4.18.2.tgz#..."', + " integrity sha512-...", + ].join("\n") + ); + + const versions = resolveFromLockFile(process.cwd()); + assert(versions); + expect(versions.get("lodash")).toBe("4.17.21"); + expect(versions.get("express")).toBe("4.18.2"); + }); + + it("handles scoped packages", ({ expect }) => { + fs.writeFileSync( + "yarn.lock", + [ + "# yarn lockfile v1", + "", + "", + '"@types/node@^22.0.0":', + ' version "22.15.17"', + ' resolved "https://registry.yarnpkg.com/@types/node/-/node-22.15.17.tgz#..."', + ].join("\n") + ); + + const versions = resolveFromLockFile(process.cwd()); + assert(versions); + expect(versions.get("@types/node")).toBe("22.15.17"); + }); + + it("skips aliases with npm: protocol", ({ expect }) => { + fs.writeFileSync( + "yarn.lock", + [ + "# yarn lockfile v1", + "", + "", + '"my-react@npm:react@^18":', + ' version "18.2.0"', + ' resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#..."', + "", + "lodash@^4.17.21:", + ' version "4.17.21"', + ' resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#..."', + ].join("\n") + ); + + const versions = resolveFromLockFile(process.cwd()); + assert(versions); + expect(versions.has("my-react")).toBe(false); + expect(versions.get("lodash")).toBe("4.17.21"); + }); + + it("returns an empty map for a merge-conflict lockfile", ({ expect }) => { + // @yarnpkg/lockfile returns type "conflict" for lockfiles with + // unresolved git merge markers + fs.writeFileSync( + "yarn.lock", + [ + "# yarn lockfile v1", + "", + "", + "lodash@^4.17.21:", + ' version "4.17.21"', + "<<<<<<< HEAD", + ' resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#abc"', + "=======", + ' resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#def"', + ">>>>>>>", + ].join("\n") + ); + + const versions = resolveFromLockFile(process.cwd()); + assert(versions); + expect(versions.size).toBe(0); + }); +}); diff --git a/packages/workers-utils/tests/package-resolution/package-resolution.test.ts b/packages/workers-utils/tests/package-resolution/package-resolution.test.ts new file mode 100644 index 0000000000..fa60b6eb8d --- /dev/null +++ b/packages/workers-utils/tests/package-resolution/package-resolution.test.ts @@ -0,0 +1,337 @@ +import assert from "node:assert"; +import * as fs from "node:fs"; +import { describe, it } from "vitest"; +import { getInstalledPackageVersion } from "../../src/package-resolution/package-resolution"; +import { runInTempDir, seed } from "../../src/test-helpers"; + +/** + * Convenience wrapper that calls {@link getInstalledPackageVersion} with + * caching disabled, so each test gets a fresh lockfile parse without + * needing global cache teardown. + * + * @param packageName - The name of the target package + * @param projectPath - The path of the project to check + * @returns The installed version string, or `undefined` + */ +function resolvePackageVersion(packageName: string, projectPath: string) { + return getInstalledPackageVersion(packageName, projectPath, { + cache: false, + }); +} + +describe("getInstalledPackageVersion", () => { + runInTempDir(); + + it("returns the version from a lockfile when present", ({ expect }) => { + fs.writeFileSync( + "package-lock.json", + JSON.stringify({ + lockfileVersion: 3, + packages: { + "": { name: "test", version: "1.0.0" }, + "node_modules/lodash": { version: "4.17.21" }, + }, + }) + ); + + const version = resolvePackageVersion("lodash", process.cwd()); + expect(version).toBe("4.17.21"); + }); + + it("falls back to node_modules when package is not in the lockfile", async ({ + expect, + }) => { + // Lockfile exists but doesn't contain "express" + fs.writeFileSync( + "package-lock.json", + JSON.stringify({ + lockfileVersion: 3, + packages: { + "": { name: "test", version: "1.0.0" }, + "node_modules/lodash": { version: "4.17.21" }, + }, + }) + ); + + // Seed node_modules with "express" + await seed({ + "node_modules/express/package.json": JSON.stringify({ + name: "express", + version: "4.18.2", + main: "index.js", + }), + "node_modules/express/index.js": "module.exports = {}", + }); + + const version = resolvePackageVersion("express", process.cwd()); + expect(version).toBe("4.18.2"); + }); + + it("falls back to node_modules when no lockfile exists", async ({ + expect, + }) => { + await seed({ + "node_modules/lodash/package.json": JSON.stringify({ + name: "lodash", + version: "4.17.21", + main: "index.js", + }), + "node_modules/lodash/index.js": "module.exports = {}", + }); + + const version = resolvePackageVersion("lodash", process.cwd()); + expect(version).toBe("4.17.21"); + }); + + it("prefers the lockfile version over a differing node_modules version", async ({ + expect, + }) => { + fs.writeFileSync( + "package-lock.json", + JSON.stringify({ + lockfileVersion: 3, + packages: { + "": { name: "test", version: "1.0.0" }, + "node_modules/lodash": { version: "4.17.21" }, + }, + }) + ); + + // node_modules has a different (older) version + await seed({ + "node_modules/lodash/package.json": JSON.stringify({ + name: "lodash", + version: "4.17.20", + main: "index.js", + }), + "node_modules/lodash/index.js": "module.exports = {}", + }); + + const version = resolvePackageVersion("lodash", process.cwd()); + // Lockfile takes precedence + expect(version).toBe("4.17.21"); + }); + + it("falls back to node_modules for aliased deps (lockfile skips aliases)", async ({ + expect, + }) => { + // The lockfile has an alias entry — aliases are intentionally excluded + // from lockfile results so they fall through to node_modules resolution, + // which handles vite+ bundledVersions correctly. + fs.writeFileSync( + "package-lock.json", + JSON.stringify({ + lockfileVersion: 3, + packages: { + "": { name: "test", version: "1.0.0" }, + "node_modules/vite": { + // `name` differs from key → alias → skipped by lockfile parser + name: "@voidzero-dev/vite-plus-core", + version: "0.2.2", + }, + "node_modules/lodash": { version: "4.17.21" }, + }, + }) + ); + + // node_modules has the aliased package with bundledVersions + await seed({ + "node_modules/vite/package.json": JSON.stringify({ + name: "@voidzero-dev/vite-plus-core", + version: "0.2.2", + bundledVersions: { + vite: "8.1.2", + rolldown: "1.0.0", + }, + main: "index.js", + }), + "node_modules/vite/index.js": "module.exports = {}", + }); + + const version = resolvePackageVersion("vite", process.cwd()); + // Lockfile skipped the alias → node_modules fallback → bundledVersions + expect(version).toBe("8.1.2"); + }); + + it("returns undefined when the package is not installed anywhere", ({ + expect, + }) => { + // No lockfile, no node_modules + const version = resolvePackageVersion("nonexistent-pkg", process.cwd()); + expect(version).toBeUndefined(); + }); + + it("returns undefined when lockfile exists but package is absent from both", ({ + expect, + }) => { + fs.writeFileSync( + "package-lock.json", + JSON.stringify({ + lockfileVersion: 3, + packages: { + "": { name: "test", version: "1.0.0" }, + "node_modules/lodash": { version: "4.17.21" }, + }, + }) + ); + + // Use a package name that doesn't exist anywhere in the monorepo's + // node_modules tree — "express" would be found via require.resolve + // walking up to the root. + const version = resolvePackageVersion( + "@test/nonexistent-pkg-12345", + process.cwd() + ); + expect(version).toBeUndefined(); + }); + + it("resolves from a pnpm lockfile", ({ expect }) => { + fs.writeFileSync( + "pnpm-lock.yaml", + [ + "lockfileVersion: '9.0'", + "", + "importers:", + " .:", + " dependencies:", + " lodash:", + " specifier: ^4.17.21", + " version: 4.17.21", + ].join("\n") + ); + + const version = resolvePackageVersion("lodash", process.cwd()); + expect(version).toBe("4.17.21"); + }); + + it("resolves from a yarn berry lockfile", ({ expect }) => { + fs.writeFileSync( + "yarn.lock", + [ + "__metadata:", + " version: 8", + "", + '"lodash@npm:^4.17.21":', + " version: 4.17.21", + ' resolution: "lodash@npm:4.17.21"', + " languageName: node", + " linkType: hard", + ].join("\n") + ); + + const version = resolvePackageVersion("lodash", process.cwd()); + expect(version).toBe("4.17.21"); + }); + + it("resolves from a bun lockfile", ({ expect }) => { + fs.writeFileSync( + "bun.lock", + JSON.stringify({ + lockfileVersion: 0, + packages: { + lodash: ["lodash@4.17.21", {}], + }, + }) + ); + + const version = resolvePackageVersion("lodash", process.cwd()); + expect(version).toBe("4.17.21"); + }); + + it("stopAtProjectPath prevents lockfile discovery in ancestor directories", ({ + expect, + }) => { + // Lockfile is in the root (parent), but we query from a sub-project + // with stopAtProjectPath: true — the lockfile should NOT be found + fs.writeFileSync( + "package-lock.json", + JSON.stringify({ + lockfileVersion: 3, + packages: { + "": { name: "root", version: "1.0.0" }, + "node_modules/@opennextjs/cloudflare": { version: "1.14.4" }, + }, + }) + ); + + const subDir = "sub-project"; + fs.mkdirSync(subDir, { recursive: true }); + + // Without stopAtProjectPath, the ancestor lockfile is found + const versionWithoutStop = getInstalledPackageVersion( + "@opennextjs/cloudflare", + `${process.cwd()}/${subDir}`, + { + cache: false, + } + ); + expect(versionWithoutStop).toBe("1.14.4"); + + // With stopAtProjectPath, discovery stops at the sub-project directory + const versionWithStop = getInstalledPackageVersion( + "@opennextjs/cloudflare", + `${process.cwd()}/${subDir}`, + { + stopAtProjectPath: true, + cache: false, + } + ); + expect(versionWithStop).toBeUndefined(); + }); + + it("resolves from a pnpm v5 single-project lockfile", ({ expect }) => { + fs.writeFileSync( + "pnpm-lock.yaml", + [ + "lockfileVersion: 5.4", + "", + "specifiers:", + " lodash: ^4.17.21", + "", + "dependencies:", + " lodash: 4.17.21", + ].join("\n") + ); + + const version = resolvePackageVersion("lodash", process.cwd()); + expect(version).toBe("4.17.21"); + }); + + it("handles pnpm v5 alias falling through to node_modules", async ({ + expect, + }) => { + fs.writeFileSync( + "pnpm-lock.yaml", + [ + "lockfileVersion: 5.4", + "", + "specifiers:", + " node-types: npm:@types/node@^16", + " lodash: ^4.17.21", + "", + "dependencies:", + " node-types: /@types/node/16.18.0", + " lodash: 4.17.21", + ].join("\n") + ); + + // "node-types" alias is skipped by lockfile, but resolvable via node_modules + await seed({ + "node_modules/node-types/package.json": JSON.stringify({ + name: "@types/node", + version: "16.18.0", + main: "index.js", + }), + "node_modules/node-types/index.js": "module.exports = {}", + }); + + // lodash comes from lockfile + const lodashVersion = resolvePackageVersion("lodash", process.cwd()); + expect(lodashVersion).toBe("4.17.21"); + + // node-types falls through to node_modules (alias skipped by lockfile) + const nodeTypesVersion = resolvePackageVersion("node-types", process.cwd()); + assert(nodeTypesVersion); + expect(nodeTypesVersion).toBe("16.18.0"); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 089e62d9a7..37dfb223b3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4220,9 +4220,15 @@ importers: '@types/node': specifier: 22.15.17 version: 22.15.17 + '@types/yarnpkg__lockfile': + specifier: ^1.1.9 + version: 1.1.9 '@vitest/ui': specifier: catalog:default version: 4.1.0(vitest@4.1.0) + '@yarnpkg/lockfile': + specifier: ^1.1.0 + version: 1.1.0 ci-info: specifier: catalog:default version: 4.4.0 @@ -4271,6 +4277,9 @@ importers: xdg-app-paths: specifier: ^8.3.0 version: 8.3.0 + yaml: + specifier: ^2.8.1 + version: 2.8.1 packages/workflows-shared: dependencies: @@ -9474,6 +9483,9 @@ packages: '@types/yargs@17.0.24': resolution: {integrity: sha512-6i0aC7jV6QzQB8ne1joVZ0eSFIstHsCrobmOtghM11yGlH0j43FKL2UhWdELkyps0zuf7qVTUVCCR+tgSlyLLw==} + '@types/yarnpkg__lockfile@1.1.9': + resolution: {integrity: sha512-GD4Fk15UoP5NLCNor51YdfL9MSdldKCqOC9EssrRw3HVfar9wUZ5y8Lfnp+qVD6hIinLr8ygklDYnmlnlQo12Q==} + '@types/yauzl@2.10.3': resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} @@ -9682,6 +9694,9 @@ packages: '@webcontainer/env@1.1.0': resolution: {integrity: sha512-DreJFHUui8vq1N3nQGU3HwK5UI4hVNW7VQfhAOmeQbBVnpKtGhoNobjDNF8LzlN7ssmFI9Uhkc9Au4mtKvPK6Q==} + '@yarnpkg/lockfile@1.1.0': + resolution: {integrity: sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==} + JSONStream@1.3.5: resolution: {integrity: sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==} hasBin: true @@ -20493,6 +20508,8 @@ snapshots: dependencies: '@types/yargs-parser': 20.2.1 + '@types/yarnpkg__lockfile@1.1.9': {} + '@types/yauzl@2.10.3': dependencies: '@types/node': 22.15.17 @@ -20871,6 +20888,8 @@ snapshots: '@webcontainer/env@1.1.0': {} + '@yarnpkg/lockfile@1.1.0': {} + JSONStream@1.3.5: dependencies: jsonparse: 1.3.1 From 9e5beecc9fd50a62ca8b0e300316bca26f3565e8 Mon Sep 17 00:00:00 2001 From: Dario Piotrowicz Date: Fri, 17 Jul 2026 11:11:34 +0100 Subject: [PATCH 2/4] avoid module-scoped lockfile cache --- .../deploy/helpers/package-dependencies.ts | 9 ++++- packages/workers-utils/src/index.ts | 2 + .../src/package-resolution/index.ts | 1 + .../lockfiles-resolution/index.ts | 1 + .../lockfiles-resolution/resolve.ts | 40 +++++++++++-------- .../package-resolution/package-resolution.ts | 16 +++++--- .../lockfile-resolution/resolve.test.ts | 38 ++++++++++++++++-- .../lockfile-resolution/share.ts | 4 +- .../package-resolution.test.ts | 15 ++----- 9 files changed, 85 insertions(+), 41 deletions(-) diff --git a/packages/deploy-helpers/src/deploy/helpers/package-dependencies.ts b/packages/deploy-helpers/src/deploy/helpers/package-dependencies.ts index c15cc73acd..ffb20b45be 100644 --- a/packages/deploy-helpers/src/deploy/helpers/package-dependencies.ts +++ b/packages/deploy-helpers/src/deploy/helpers/package-dependencies.ts @@ -5,6 +5,7 @@ import { parsePackageJSON, } from "@cloudflare/workers-utils"; import { logger } from "../../shared/context"; +import type { LockfileCache } from "@cloudflare/workers-utils"; /** * A single npm package dependency entry, matching the upload API schema @@ -64,6 +65,11 @@ export async function collectPackageDependencies( const result: PackageDependency[] = []; + // Create a function-scoped cache so the lockfile is parsed once and + // shared across all per-package lookups within this single collection + // pass. The cache is garbage-collected when this function returns. + const lockfileCache: LockfileCache = new Map(); + for (const [dependencyName, packageJsonVersion] of Object.entries( allDependencies )) { @@ -82,7 +88,8 @@ export async function collectPackageDependencies( const installedVersion = getInstalledPackageVersion( dependencyName, - projectPath + projectPath, + { cache: lockfileCache } ); if (!installedVersion) { diff --git a/packages/workers-utils/src/index.ts b/packages/workers-utils/src/index.ts index d5c0987d54..15e9ab76ca 100644 --- a/packages/workers-utils/src/index.ts +++ b/packages/workers-utils/src/index.ts @@ -95,6 +95,8 @@ export { getInstalledPackageVersion, } from "./package-resolution"; +export type { LockfileCache } from "./package-resolution"; + export * from "./constants"; export { mapWorkerMetadataBindings } from "./map-worker-metadata-bindings"; diff --git a/packages/workers-utils/src/package-resolution/index.ts b/packages/workers-utils/src/package-resolution/index.ts index fc28c2b1a0..b495266511 100644 --- a/packages/workers-utils/src/package-resolution/index.ts +++ b/packages/workers-utils/src/package-resolution/index.ts @@ -1,2 +1,3 @@ export { getPackagePath, isPackageInstalled } from "./node-modules"; export { getInstalledPackageVersion } from "./package-resolution"; +export type { LockfileCache } from "./lockfiles-resolution"; diff --git a/packages/workers-utils/src/package-resolution/lockfiles-resolution/index.ts b/packages/workers-utils/src/package-resolution/lockfiles-resolution/index.ts index 7f997be002..55519f277d 100644 --- a/packages/workers-utils/src/package-resolution/lockfiles-resolution/index.ts +++ b/packages/workers-utils/src/package-resolution/lockfiles-resolution/index.ts @@ -1 +1,2 @@ export { getInstalledVersionsFromLockfile } from "./resolve"; +export type { LockfileCache } from "./resolve"; diff --git a/packages/workers-utils/src/package-resolution/lockfiles-resolution/resolve.ts b/packages/workers-utils/src/package-resolution/lockfiles-resolution/resolve.ts index 33ddba2a53..22591110ef 100644 --- a/packages/workers-utils/src/package-resolution/lockfiles-resolution/resolve.ts +++ b/packages/workers-utils/src/package-resolution/lockfiles-resolution/resolve.ts @@ -8,15 +8,19 @@ import { parseYarnLockfile } from "./yarn"; import type { LockfileName } from "./discovery"; /** - * Memoization cache for parsed lockfile version maps. + * A caller-owned memoization cache for parsed lockfile version maps. + * * Keyed by `lockfilePath + "\0" + projectPath` so that different workspace * packages sharing the same pnpm lockfile each get their own entry (pnpm * lockfiles use per-importer dependency maps). The null byte separator * cannot appear in file paths, so the composite key is unambiguous. - * Lockfiles are not expected to change during a single process invocation - * (e.g. a `wrangler deploy`), so a simple cache is sufficient. + * + * Create one at the call-site that makes repeated lookups (e.g. the loop + * in `collectPackageDependencies`) and pass it via `opts.cache`. The cache + * is then garbage-collected when the owning function returns, avoiding + * module-level mutable state. */ -const cache = new Map>(); +export type LockfileCache = Map>; /** * Resolves installed package versions from the nearest lockfile. @@ -26,15 +30,17 @@ const cache = new Map>(); * and returns a `Map` for the project's * top-level dependencies. * - * When caching is enabled (the default), results are memoized per lockfile - * path and project path, so repeated per-package lookups within a single - * process (e.g. inside `collectPackageDependencies`) are O(1) after the - * initial parse. + * When a `cache` Map is provided, results are memoized per lockfile path + * and project path, so repeated per-package lookups within a single + * operation (e.g. inside `collectPackageDependencies`) are O(1) after the + * initial parse. The caller owns the cache lifetime. * * @param projectPath - Absolute path to the project directory * @param opts - Options - * @param opts.cache - Whether to use the memoization cache (default: `true`). - * Set to `false` to always re-read and re-parse the lockfile. + * @param opts.cache - A caller-owned {@link LockfileCache} for memoization. + * When provided, parsed results are stored and re-used + * from this map. When omitted, every call re-reads and + * re-parses the lockfile. * @param opts.stopAtProjectPath - If `true`, do not search for lockfiles * above `projectPath`. Prevents a monorepo root * lockfile from being used when only the @@ -44,7 +50,7 @@ const cache = new Map>(); */ export function getInstalledVersionsFromLockfile( projectPath: string, - opts: { cache?: boolean; stopAtProjectPath?: boolean } = {} + opts: { cache?: LockfileCache; stopAtProjectPath?: boolean } = {} ): Map | undefined { const found = findLockfile(projectPath, { last: opts.stopAtProjectPath === true ? projectPath : undefined, @@ -54,23 +60,23 @@ export function getInstalledVersionsFromLockfile( } const { lockfilePath, name } = found; - const useCache = opts.cache !== false; - const cacheKey = `${lockfilePath}\0${projectPath}`; + const cacheStore = opts.cache; - if (useCache) { - const cached = cache.get(cacheKey); + if (cacheStore) { + const cacheKey = `${lockfilePath}\0${projectPath}`; + const cached = cacheStore.get(cacheKey); if (cached) { return cached; } const versions = parseLockfile(lockfilePath, name, projectPath); if (versions) { - cache.set(cacheKey, versions); + cacheStore.set(cacheKey, versions); } return versions; } - // Caching disabled — parse fresh, skip cache entirely + // No cache provided — parse fresh every time return parseLockfile(lockfilePath, name, projectPath); } diff --git a/packages/workers-utils/src/package-resolution/package-resolution.ts b/packages/workers-utils/src/package-resolution/package-resolution.ts index e4d182ed13..62fd386d07 100644 --- a/packages/workers-utils/src/package-resolution/package-resolution.ts +++ b/packages/workers-utils/src/package-resolution/package-resolution.ts @@ -1,14 +1,15 @@ import { getInstalledVersionsFromLockfile } from "./lockfiles-resolution"; import { getInstalledPackageVersionFromNodeModules } from "./node-modules"; +import type { LockfileCache } from "./lockfiles-resolution"; /** * Gets the exact version of an npm package installed in a project. * * Resolution strategy (first match wins): * 1. **Lockfile** — looks up the version in the nearest parseable lockfile - * (pnpm-lock.yaml, package-lock.json, yarn.lock, or bun.lock). The - * lockfile is parsed once and memoized (unless caching is disabled), so - * repeated per-package calls within the same process are O(1). + * (pnpm-lock.yaml, package-lock.json, yarn.lock, or bun.lock). When a + * {@link LockfileCache} is provided via `opts.cache`, the lockfile is + * parsed once and subsequent lookups are O(1). * Aliases (npm: protocol, vite+, etc.) are intentionally excluded from * lockfile results so they fall through to the node_modules path, which * handles vite's `bundledVersions` convention correctly. @@ -20,7 +21,10 @@ import { getInstalledPackageVersionFromNodeModules } from "./node-modules"; * @param projectPath - The path of the project to check * @param opts - Options * @param opts.stopAtProjectPath - If `true`, stop walking up at the project's path - * @param opts.cache - Whether to use the lockfile memoization cache (default: `true`) + * @param opts.cache - A caller-owned {@link LockfileCache} for memoization. + * When provided, parsed lockfile results are stored and + * re-used from this map. When omitted, every call + * re-reads and re-parses the lockfile. * @returns The installed version string, or `undefined` if the package is not installed */ export function getInstalledPackageVersion( @@ -28,10 +32,10 @@ export function getInstalledPackageVersion( projectPath: string, opts: { stopAtProjectPath?: boolean; - cache?: boolean; + cache?: LockfileCache; } = {} ): string | undefined { - // Fast path: consult the lockfile first (memoized by default, O(1) per lookup) + // Fast path: consult the lockfile first (O(1) per lookup when cache is provided) const lockfileVersions = getInstalledVersionsFromLockfile(projectPath, { cache: opts.cache, stopAtProjectPath: opts.stopAtProjectPath, diff --git a/packages/workers-utils/tests/package-resolution/lockfile-resolution/resolve.test.ts b/packages/workers-utils/tests/package-resolution/lockfile-resolution/resolve.test.ts index 9c8c9117ec..413ab9c171 100644 --- a/packages/workers-utils/tests/package-resolution/lockfile-resolution/resolve.test.ts +++ b/packages/workers-utils/tests/package-resolution/lockfile-resolution/resolve.test.ts @@ -150,7 +150,7 @@ describe("lockfile resolution — generic", () => { // ----------------------------------------------------------------------- describe("memoization", () => { - it("returns the same map instance for repeated calls (cache hit)", ({ + it("returns the same map instance for repeated calls with a shared cache", ({ expect, }) => { fs.writeFileSync( @@ -164,9 +164,34 @@ describe("lockfile resolution — generic", () => { }) ); + const cache = new Map>(); + const first = getInstalledVersionsFromLockfile(process.cwd(), { + cache, + }); + const second = getInstalledVersionsFromLockfile(process.cwd(), { + cache, + }); + expect(first).toBe(second); + }); + + it("re-parses when no cache is provided", ({ expect }) => { + fs.writeFileSync( + "package-lock.json", + JSON.stringify({ + lockfileVersion: 3, + packages: { + "": { name: "test", version: "1.0.0" }, + "node_modules/lodash": { version: "4.17.21" }, + }, + }) + ); + const first = getInstalledVersionsFromLockfile(process.cwd()); const second = getInstalledVersionsFromLockfile(process.cwd()); - expect(first).toBe(second); + // Without a cache, each call returns a fresh map instance + expect(first).not.toBe(second); + // But the contents are equivalent + expect(first).toEqual(second); }); it("returns distinct results for different projectPaths in a pnpm monorepo", ({ @@ -198,8 +223,13 @@ describe("lockfile resolution — generic", () => { fs.mkdirSync(appADir, { recursive: true }); fs.mkdirSync(appBDir, { recursive: true }); - const versionsA = getInstalledVersionsFromLockfile(appADir); - const versionsB = getInstalledVersionsFromLockfile(appBDir); + const cache = new Map>(); + const versionsA = getInstalledVersionsFromLockfile(appADir, { + cache, + }); + const versionsB = getInstalledVersionsFromLockfile(appBDir, { + cache, + }); assert(versionsA); assert(versionsB); diff --git a/packages/workers-utils/tests/package-resolution/lockfile-resolution/share.ts b/packages/workers-utils/tests/package-resolution/lockfile-resolution/share.ts index 8543b4b990..797e78def0 100644 --- a/packages/workers-utils/tests/package-resolution/lockfile-resolution/share.ts +++ b/packages/workers-utils/tests/package-resolution/lockfile-resolution/share.ts @@ -2,11 +2,11 @@ import { getInstalledVersionsFromLockfile } from "../../../src/package-resolutio /** * Convenience wrapper that calls {@link getInstalledVersionsFromLockfile} - * with caching disabled, so each test gets a fresh parse. + * without a cache, so each test gets a fresh parse. * * @param projectPath - Absolute path to the project directory * @returns A map of package names to resolved versions, or `undefined` */ export function resolveFromLockFile(projectPath: string) { - return getInstalledVersionsFromLockfile(projectPath, { cache: false }); + return getInstalledVersionsFromLockfile(projectPath); } diff --git a/packages/workers-utils/tests/package-resolution/package-resolution.test.ts b/packages/workers-utils/tests/package-resolution/package-resolution.test.ts index fa60b6eb8d..93a6a9b289 100644 --- a/packages/workers-utils/tests/package-resolution/package-resolution.test.ts +++ b/packages/workers-utils/tests/package-resolution/package-resolution.test.ts @@ -5,18 +5,15 @@ import { getInstalledPackageVersion } from "../../src/package-resolution/package import { runInTempDir, seed } from "../../src/test-helpers"; /** - * Convenience wrapper that calls {@link getInstalledPackageVersion} with - * caching disabled, so each test gets a fresh lockfile parse without - * needing global cache teardown. + * Convenience wrapper that calls {@link getInstalledPackageVersion} without + * a cache, so each test gets a fresh lockfile parse. * * @param packageName - The name of the target package * @param projectPath - The path of the project to check * @returns The installed version string, or `undefined` */ function resolvePackageVersion(packageName: string, projectPath: string) { - return getInstalledPackageVersion(packageName, projectPath, { - cache: false, - }); + return getInstalledPackageVersion(packageName, projectPath); } describe("getInstalledPackageVersion", () => { @@ -260,10 +257,7 @@ describe("getInstalledPackageVersion", () => { // Without stopAtProjectPath, the ancestor lockfile is found const versionWithoutStop = getInstalledPackageVersion( "@opennextjs/cloudflare", - `${process.cwd()}/${subDir}`, - { - cache: false, - } + `${process.cwd()}/${subDir}` ); expect(versionWithoutStop).toBe("1.14.4"); @@ -273,7 +267,6 @@ describe("getInstalledPackageVersion", () => { `${process.cwd()}/${subDir}`, { stopAtProjectPath: true, - cache: false, } ); expect(versionWithStop).toBeUndefined(); From a073bd9f25f9b3402c725563cf0c2bc5a9faafe7 Mon Sep 17 00:00:00 2001 From: Dario Piotrowicz Date: Mon, 20 Jul 2026 10:45:35 +0100 Subject: [PATCH 3/4] cache lockfile discovery to avoid redundant directory walks --- .../deploy/helpers/package-dependencies.ts | 4 +- packages/workers-utils/src/index.ts | 1 + .../src/package-resolution/index.ts | 1 + .../lockfiles-resolution/index.ts | 5 +- .../lockfiles-resolution/resolve.ts | 82 ++++++++++++++----- .../lockfile-resolution/resolve.test.ts | 9 +- 6 files changed, 77 insertions(+), 25 deletions(-) diff --git a/packages/deploy-helpers/src/deploy/helpers/package-dependencies.ts b/packages/deploy-helpers/src/deploy/helpers/package-dependencies.ts index ffb20b45be..8b0830a1a0 100644 --- a/packages/deploy-helpers/src/deploy/helpers/package-dependencies.ts +++ b/packages/deploy-helpers/src/deploy/helpers/package-dependencies.ts @@ -1,11 +1,11 @@ import { access, readFile } from "node:fs/promises"; import path from "node:path"; import { + createLockfileCache, getInstalledPackageVersion, parsePackageJSON, } from "@cloudflare/workers-utils"; import { logger } from "../../shared/context"; -import type { LockfileCache } from "@cloudflare/workers-utils"; /** * A single npm package dependency entry, matching the upload API schema @@ -68,7 +68,7 @@ export async function collectPackageDependencies( // Create a function-scoped cache so the lockfile is parsed once and // shared across all per-package lookups within this single collection // pass. The cache is garbage-collected when this function returns. - const lockfileCache: LockfileCache = new Map(); + const lockfileCache = createLockfileCache(); for (const [dependencyName, packageJsonVersion] of Object.entries( allDependencies diff --git a/packages/workers-utils/src/index.ts b/packages/workers-utils/src/index.ts index 15e9ab76ca..f8e3d5c4df 100644 --- a/packages/workers-utils/src/index.ts +++ b/packages/workers-utils/src/index.ts @@ -90,6 +90,7 @@ export * from "./errors"; export { assertNever } from "./assert-never"; export { + createLockfileCache, getPackagePath, isPackageInstalled, getInstalledPackageVersion, diff --git a/packages/workers-utils/src/package-resolution/index.ts b/packages/workers-utils/src/package-resolution/index.ts index b495266511..114b30cb0e 100644 --- a/packages/workers-utils/src/package-resolution/index.ts +++ b/packages/workers-utils/src/package-resolution/index.ts @@ -1,3 +1,4 @@ +export { createLockfileCache } from "./lockfiles-resolution"; export { getPackagePath, isPackageInstalled } from "./node-modules"; export { getInstalledPackageVersion } from "./package-resolution"; export type { LockfileCache } from "./lockfiles-resolution"; diff --git a/packages/workers-utils/src/package-resolution/lockfiles-resolution/index.ts b/packages/workers-utils/src/package-resolution/lockfiles-resolution/index.ts index 55519f277d..53b65bd86a 100644 --- a/packages/workers-utils/src/package-resolution/lockfiles-resolution/index.ts +++ b/packages/workers-utils/src/package-resolution/lockfiles-resolution/index.ts @@ -1,2 +1,5 @@ -export { getInstalledVersionsFromLockfile } from "./resolve"; +export { + createLockfileCache, + getInstalledVersionsFromLockfile, +} from "./resolve"; export type { LockfileCache } from "./resolve"; diff --git a/packages/workers-utils/src/package-resolution/lockfiles-resolution/resolve.ts b/packages/workers-utils/src/package-resolution/lockfiles-resolution/resolve.ts index 22591110ef..18f8e7f485 100644 --- a/packages/workers-utils/src/package-resolution/lockfiles-resolution/resolve.ts +++ b/packages/workers-utils/src/package-resolution/lockfiles-resolution/resolve.ts @@ -8,19 +8,44 @@ import { parseYarnLockfile } from "./yarn"; import type { LockfileName } from "./discovery"; /** - * A caller-owned memoization cache for parsed lockfile version maps. + * A caller-owned memoization cache for lockfile discovery and parsed version + * maps. * - * Keyed by `lockfilePath + "\0" + projectPath` so that different workspace - * packages sharing the same pnpm lockfile each get their own entry (pnpm - * lockfiles use per-importer dependency maps). The null byte separator - * cannot appear in file paths, so the composite key is unambiguous. + * - `discovery` — caches the result of {@link findLockfile} keyed by + * `projectPath + "\0" + (stopAtProjectPath ?? "")` so the directory-tree + * walk + `statSync` calls happen only once per unique project path, not + * once per dependency lookup. + * - `versions` — caches parsed lockfile version maps keyed by + * `lockfilePath + "\0" + projectPath` so that different workspace packages + * sharing the same pnpm lockfile each get their own entry (pnpm lockfiles + * use per-importer dependency maps). The null byte separator cannot appear + * in file paths, so the composite key is unambiguous. * * Create one at the call-site that makes repeated lookups (e.g. the loop * in `collectPackageDependencies`) and pass it via `opts.cache`. The cache * is then garbage-collected when the owning function returns, avoiding * module-level mutable state. */ -export type LockfileCache = Map>; +export type LockfileCache = { + /** Caches {@link findLockfile} results to avoid repeated directory walks. */ + discovery: Map< + string, + { lockfilePath: string; name: LockfileName } | undefined + >; + /** Caches parsed lockfile version maps (package name to version). */ + versions: Map>; +}; + +/** + * Creates a new, empty {@link LockfileCache}. + * + * Convenience factory so callers don't need to know the internal structure. + * + * @returns A fresh lockfile cache + */ +export function createLockfileCache(): LockfileCache { + return { discovery: new Map(), versions: new Map() }; +} /** * Resolves installed package versions from the nearest lockfile. @@ -30,17 +55,19 @@ export type LockfileCache = Map>; * and returns a `Map` for the project's * top-level dependencies. * - * When a `cache` Map is provided, results are memoized per lockfile path - * and project path, so repeated per-package lookups within a single - * operation (e.g. inside `collectPackageDependencies`) are O(1) after the - * initial parse. The caller owns the cache lifetime. + * When a `cache` is provided, both the lockfile discovery (directory walk) + * and the parsed version maps are memoized, so repeated per-package lookups + * within a single operation (e.g. inside `collectPackageDependencies`) are + * O(1) after the initial discovery + parse. The caller owns the cache + * lifetime. * * @param projectPath - Absolute path to the project directory * @param opts - Options * @param opts.cache - A caller-owned {@link LockfileCache} for memoization. - * When provided, parsed results are stored and re-used - * from this map. When omitted, every call re-reads and - * re-parses the lockfile. + * When provided, both discovery and parsed results are + * stored and re-used from this cache. When omitted, + * every call re-walks the directory tree and re-parses + * the lockfile. * @param opts.stopAtProjectPath - If `true`, do not search for lockfiles * above `projectPath`. Prevents a monorepo root * lockfile from being used when only the @@ -52,26 +79,43 @@ export function getInstalledVersionsFromLockfile( projectPath: string, opts: { cache?: LockfileCache; stopAtProjectPath?: boolean } = {} ): Map | undefined { - const found = findLockfile(projectPath, { - last: opts.stopAtProjectPath === true ? projectPath : undefined, - }); + const cacheStore = opts.cache; + + // Discover the nearest lockfile, using the cache to avoid redundant + // directory walks when called in a loop. + let found: { lockfilePath: string; name: LockfileName } | undefined; + if (cacheStore) { + const discoveryKey = `${projectPath}\0${opts.stopAtProjectPath ?? ""}`; + if (cacheStore.discovery.has(discoveryKey)) { + found = cacheStore.discovery.get(discoveryKey); + } else { + found = findLockfile(projectPath, { + last: opts.stopAtProjectPath === true ? projectPath : undefined, + }); + cacheStore.discovery.set(discoveryKey, found); + } + } else { + found = findLockfile(projectPath, { + last: opts.stopAtProjectPath === true ? projectPath : undefined, + }); + } + if (!found) { return undefined; } const { lockfilePath, name } = found; - const cacheStore = opts.cache; if (cacheStore) { const cacheKey = `${lockfilePath}\0${projectPath}`; - const cached = cacheStore.get(cacheKey); + const cached = cacheStore.versions.get(cacheKey); if (cached) { return cached; } const versions = parseLockfile(lockfilePath, name, projectPath); if (versions) { - cacheStore.set(cacheKey, versions); + cacheStore.versions.set(cacheKey, versions); } return versions; } diff --git a/packages/workers-utils/tests/package-resolution/lockfile-resolution/resolve.test.ts b/packages/workers-utils/tests/package-resolution/lockfile-resolution/resolve.test.ts index 413ab9c171..0d65c44eb8 100644 --- a/packages/workers-utils/tests/package-resolution/lockfile-resolution/resolve.test.ts +++ b/packages/workers-utils/tests/package-resolution/lockfile-resolution/resolve.test.ts @@ -2,7 +2,10 @@ import assert from "node:assert"; import * as fs from "node:fs"; import * as path from "node:path"; import { describe, it } from "vitest"; -import { getInstalledVersionsFromLockfile } from "../../../src/package-resolution/lockfiles-resolution"; +import { + createLockfileCache, + getInstalledVersionsFromLockfile, +} from "../../../src/package-resolution/lockfiles-resolution"; import { runInTempDir } from "../../../src/test-helpers"; import { resolveFromLockFile } from "./share"; @@ -164,7 +167,7 @@ describe("lockfile resolution — generic", () => { }) ); - const cache = new Map>(); + const cache = createLockfileCache(); const first = getInstalledVersionsFromLockfile(process.cwd(), { cache, }); @@ -223,7 +226,7 @@ describe("lockfile resolution — generic", () => { fs.mkdirSync(appADir, { recursive: true }); fs.mkdirSync(appBDir, { recursive: true }); - const cache = new Map>(); + const cache = createLockfileCache(); const versionsA = getInstalledVersionsFromLockfile(appADir, { cache, }); From be782c643a5162ba258a171f7f3f251421d2a537 Mon Sep 17 00:00:00 2001 From: Dario Piotrowicz Date: Mon, 20 Jul 2026 12:44:40 +0100 Subject: [PATCH 4/4] fix yarn classic issue --- .../lockfiles-resolution/yarn.ts | 8 ++-- .../lockfile-resolution/yarn.test.ts | 39 +++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/packages/workers-utils/src/package-resolution/lockfiles-resolution/yarn.ts b/packages/workers-utils/src/package-resolution/lockfiles-resolution/yarn.ts index 8a10819dcc..a73ea54fe5 100644 --- a/packages/workers-utils/src/package-resolution/lockfiles-resolution/yarn.ts +++ b/packages/workers-utils/src/package-resolution/lockfiles-resolution/yarn.ts @@ -112,15 +112,17 @@ function parseYarnClassicLockfile(content: string): Map { } // Key format: "name@^1.0.0" or "@scope/name@^1.0.0" - // Multiple specifiers: "name@^1.0.0, name@^2.0.0" (but @yarnpkg/lockfile - // splits these into separate keys) + // Multiple specifiers are kept as a single grouped key by + // @yarnpkg/lockfile (e.g. "name@^1.0.0, name@^2.0.0"); they all + // resolve to the same package/version, so split and use the first. // // Alias format: "alias-name@npm:real-name@^1.0.0" if (key.includes("@npm:")) { continue; } - const pkgName = extractYarnClassicPackageName(key); + const descriptor = key.split(", ")[0]; + const pkgName = extractYarnClassicPackageName(descriptor); if (pkgName && !versions.has(pkgName)) { versions.set(pkgName, entry.version); } diff --git a/packages/workers-utils/tests/package-resolution/lockfile-resolution/yarn.test.ts b/packages/workers-utils/tests/package-resolution/lockfile-resolution/yarn.test.ts index 7c5ce61453..8c5c52b6a0 100644 --- a/packages/workers-utils/tests/package-resolution/lockfile-resolution/yarn.test.ts +++ b/packages/workers-utils/tests/package-resolution/lockfile-resolution/yarn.test.ts @@ -209,6 +209,45 @@ describe("lockfile resolution — yarn.lock (Classic v1)", () => { expect(versions.get("lodash")).toBe("4.17.21"); }); + it("handles multiple descriptors resolving to the same package", ({ + expect, + }) => { + fs.writeFileSync( + "yarn.lock", + [ + "# yarn lockfile v1", + "", + "", + '"lodash@^4.17.0, lodash@^4.17.21":', + ' version "4.17.21"', + ' resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#..."', + " integrity sha512-...", + ].join("\n") + ); + + const versions = resolveFromLockFile(process.cwd()); + assert(versions); + expect(versions.get("lodash")).toBe("4.17.21"); + }); + + it("handles grouped scoped packages", ({ expect }) => { + fs.writeFileSync( + "yarn.lock", + [ + "# yarn lockfile v1", + "", + "", + '"@types/node@^18.0.0, @types/node@^22.0.0":', + ' version "22.15.17"', + ' resolved "https://registry.yarnpkg.com/@types/node/-/node-22.15.17.tgz#..."', + ].join("\n") + ); + + const versions = resolveFromLockFile(process.cwd()); + assert(versions); + expect(versions.get("@types/node")).toBe("22.15.17"); + }); + it("returns an empty map for a merge-conflict lockfile", ({ expect }) => { // @yarnpkg/lockfile returns type "conflict" for lockfiles with // unresolved git merge markers