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/deploy-helpers/src/deploy/helpers/package-dependencies.ts b/packages/deploy-helpers/src/deploy/helpers/package-dependencies.ts index c15cc73acd..8b0830a1a0 100644 --- a/packages/deploy-helpers/src/deploy/helpers/package-dependencies.ts +++ b/packages/deploy-helpers/src/deploy/helpers/package-dependencies.ts @@ -1,6 +1,7 @@ import { access, readFile } from "node:fs/promises"; import path from "node:path"; import { + createLockfileCache, getInstalledPackageVersion, parsePackageJSON, } from "@cloudflare/workers-utils"; @@ -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 = createLockfileCache(); + 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/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/index.ts b/packages/workers-utils/src/index.ts index d5c0987d54..f8e3d5c4df 100644 --- a/packages/workers-utils/src/index.ts +++ b/packages/workers-utils/src/index.ts @@ -90,11 +90,14 @@ export * from "./errors"; export { assertNever } from "./assert-never"; export { + createLockfileCache, getPackagePath, isPackageInstalled, 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 new file mode 100644 index 0000000000..114b30cb0e --- /dev/null +++ b/packages/workers-utils/src/package-resolution/index.ts @@ -0,0 +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/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..53b65bd86a --- /dev/null +++ b/packages/workers-utils/src/package-resolution/lockfiles-resolution/index.ts @@ -0,0 +1,5 @@ +export { + createLockfileCache, + getInstalledVersionsFromLockfile, +} from "./resolve"; +export type { LockfileCache } 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..18f8e7f485 --- /dev/null +++ b/packages/workers-utils/src/package-resolution/lockfiles-resolution/resolve.ts @@ -0,0 +1,165 @@ +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"; + +/** + * A caller-owned memoization cache for lockfile discovery and parsed version + * maps. + * + * - `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 = { + /** 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. + * + * 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 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, 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 + * 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?: LockfileCache; stopAtProjectPath?: boolean } = {} +): Map | 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; + + if (cacheStore) { + const cacheKey = `${lockfilePath}\0${projectPath}`; + const cached = cacheStore.versions.get(cacheKey); + if (cached) { + return cached; + } + + const versions = parseLockfile(lockfilePath, name, projectPath); + if (versions) { + cacheStore.versions.set(cacheKey, versions); + } + return versions; + } + + // No cache provided — parse fresh every time + 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..a73ea54fe5 --- /dev/null +++ b/packages/workers-utils/src/package-resolution/lockfiles-resolution/yarn.ts @@ -0,0 +1,153 @@ +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 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 descriptor = key.split(", ")[0]; + const pkgName = extractYarnClassicPackageName(descriptor); + 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..62fd386d07 --- /dev/null +++ b/packages/workers-utils/src/package-resolution/package-resolution.ts @@ -0,0 +1,54 @@ +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). 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. + * 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 - 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( + packageName: string, + projectPath: string, + opts: { + stopAtProjectPath?: boolean; + cache?: LockfileCache; + } = {} +): string | undefined { + // Fast path: consult the lockfile first (O(1) per lookup when cache is provided) + 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..0d65c44eb8 --- /dev/null +++ b/packages/workers-utils/tests/package-resolution/lockfile-resolution/resolve.test.ts @@ -0,0 +1,252 @@ +import assert from "node:assert"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { describe, it } from "vitest"; +import { + createLockfileCache, + 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 with a shared cache", ({ + 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 cache = createLockfileCache(); + 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()); + // 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", ({ + 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 cache = createLockfileCache(); + const versionsA = getInstalledVersionsFromLockfile(appADir, { + cache, + }); + const versionsB = getInstalledVersionsFromLockfile(appBDir, { + cache, + }); + + 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..797e78def0 --- /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} + * 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); +} 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..8c5c52b6a0 --- /dev/null +++ b/packages/workers-utils/tests/package-resolution/lockfile-resolution/yarn.test.ts @@ -0,0 +1,274 @@ +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("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 + 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..93a6a9b289 --- /dev/null +++ b/packages/workers-utils/tests/package-resolution/package-resolution.test.ts @@ -0,0 +1,330 @@ +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} 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); +} + +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}` + ); + 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, + } + ); + 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