Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 23 additions & 59 deletions src/overrides/context-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,11 @@ import type { OverrideContext, PackageManager, SkippedDetector, Logger } from ".
import type { AuditLogHandle } from "../audit-log/index.js";
import { extractOverrideEntries } from "./parsing/package-json.js";
import { walkInstalledTree } from "./parsing/installed-tree.js";
import { loadFromPackageLock } from "../parsers/package-lock.js";
import { loadFromPnpmLock } from "../parsers/pnpm-lock.js";
import { loadFromYarnLock } from "../parsers/yarn-lock.js";
import { loadFromBunLock } from "../parsers/bun-lock.js";
import { readSelectedLockfile, selectRootLockfile, type SelectedLockfile } from "../parsers/index.js";
import type { LockfileName } from "../constants.js";
import { scanAllImports, usageScanTruncationWarning } from "../usage/scanner.js";
import { readWorkspaceMemberManifests } from "../utils/package-json.js";
import { isNodeBuiltinSpecifier } from "../utils/node-builtins.js";
import { findNpmLockfile } from "../utils/npm-lockfile.js";
import { getDeclaredPackages } from "./detectors/phantom-utils.js";

type LockfileReadResult =
Expand All @@ -37,9 +34,12 @@ export function buildOverrideContext(
const raw = readFileSync(pkgJsonPath, "utf8");
const parsed = JSON.parse(raw) as Record<string, unknown>;
const overrideEntries = extractOverrideEntries(parsed);
const packageManager = detectPackageManager(projectPath);
// The same root lockfile the scan reads, so the override rules see the tree
// the findings came from (#1497).
const lockfile = selectRootLockfile(projectPath);
const packageManager: PackageManager = lockfile ? PACKAGE_MANAGER_BY_LOCKFILE[lockfile.name] : "unknown";

const readResult = readLockfileNames(projectPath, packageManager);
const readResult = readLockfileNames(lockfile);
const lockfilePackageNames = readResult.kind === "ok" ? readResult.names : new Set<string>();
const lockfileVersions = readResult.kind === "ok" ? readResult.versions : new Map<string, string[]>();
const nodeModulesExists = existsSync(join(projectPath, "node_modules"));
Expand Down Expand Up @@ -132,13 +132,14 @@ export function buildOverrideContext(
};
}

function detectPackageManager(projectPath: string): PackageManager {
if (existsSync(join(projectPath, "pnpm-lock.yaml"))) return "pnpm";
if (existsSync(join(projectPath, "yarn.lock"))) return "yarn";
if (existsSync(join(projectPath, "bun.lock"))) return "bun";
if (findNpmLockfile(projectPath)) return "npm";
return "unknown";
}
const PACKAGE_MANAGER_BY_LOCKFILE: Readonly<Record<LockfileName, PackageManager>> = {
"package-lock.json": "npm",
"npm-shrinkwrap.json": "npm",
"pnpm-lock.yaml": "pnpm",
"yarn.lock": "yarn",
"bun.lock": "bun",
};


/**
* Groups resolved lockfile versions by package name. A package can appear at
Expand All @@ -159,52 +160,15 @@ function versionsByName(refs: { name: string; version: string }[]): Map<string,
return byName;
}

function readLockfileNames(projectPath: string, pm: PackageManager): LockfileReadResult {
if (pm === "npm") {
const lockPath = findNpmLockfile(projectPath);
if (lockPath === null) return { kind: "absent" };
try {
const refs = loadFromPackageLock(lockPath, false);
return { kind: "ok", names: new Set(refs.map((r) => r.name)), versions: versionsByName(refs) };
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
return { kind: "error", message: `${basename(lockPath)}: ${message}` };
}
}
if (pm === "pnpm") {
const lockPath = join(projectPath, "pnpm-lock.yaml");
if (!existsSync(lockPath)) return { kind: "absent" };
try {
const refs = loadFromPnpmLock(lockPath, false);
return { kind: "ok", names: new Set(refs.map((r) => r.name)), versions: versionsByName(refs) };
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
return { kind: "error", message: `pnpm-lock.yaml: ${message}` };
}
}
if (pm === "yarn") {
const lockPath = join(projectPath, "yarn.lock");
if (!existsSync(lockPath)) return { kind: "absent" };
try {
const refs = loadFromYarnLock(lockPath);
return { kind: "ok", names: new Set(refs.map((r) => r.name)), versions: versionsByName(refs) };
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
return { kind: "error", message: `yarn.lock: ${message}` };
}
}
if (pm === "bun") {
const lockPath = join(projectPath, "bun.lock");
if (!existsSync(lockPath)) return { kind: "absent" };
try {
const refs = loadFromBunLock(lockPath, false);
return { kind: "ok", names: new Set(refs.map((r) => r.name)), versions: versionsByName(refs) };
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
return { kind: "error", message: `bun.lock: ${message}` };
}
function readLockfileNames(lockfile: SelectedLockfile | null): LockfileReadResult {
if (lockfile === null) return { kind: "absent" };
try {
const refs = readSelectedLockfile(lockfile);
return { kind: "ok", names: new Set(refs.map((r) => r.name)), versions: versionsByName(refs) };
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
return { kind: "error", message: `${basename(lockfile.filePath)}: ${message}` };
}
return { kind: "absent" };
}

function seedRootDeclarations(
Expand Down
22 changes: 22 additions & 0 deletions src/parsers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,28 @@ function buildLockfileScan(
};
}

export interface SelectedLockfile {
readonly filePath: string;
readonly name: LockfileName;
}

/**
* The lockfile a scan of `projectRoot` reads at that folder's root, by the same
* rules `loadPackages` uses. Anything that has to agree with the scan about
* which tree it is looking at, such as the override rules, takes its lockfile
* from here rather than choosing one by its own order (#1497). A nested
* lockfile is deliberately not returned: it belongs to a different manifest.
*/
export function selectRootLockfile(projectRoot: string): SelectedLockfile | null {
const root = findRootLockfile(projectRoot);
return root === null ? null : { filePath: root.filePath, name: root.name };
}

/** Reads every resolved package from a selected lockfile, dev dependencies included. */
export function readSelectedLockfile(lockfile: SelectedLockfile): PackageRef[] {
return LOCKFILE_HANDLERS[lockfile.name].load(lockfile.filePath, false);
}

export function loadPackages(projectRoot: string, prodOnly: boolean, maxDepth: number): ScanInput {
const rootLockfile = findRootLockfile(projectRoot);
if (rootLockfile !== null) {
Expand Down
85 changes: 85 additions & 0 deletions tests/overrides/context-builder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -470,3 +470,88 @@ describe("buildOverrideContext", () => {
expect(ctx.installedVersions.size).toBe(0);
});
});

describe("buildOverrideContext lockfile selection (#1497)", () => {
let dir: string;
const build = () =>
buildOverrideContext(dir, {
auditLog: NULL_AUDIT_LOG,
logger: makeNoopLogger() as any,
checkNetwork: false,
});

// Two managers' lockfiles side by side, each holding a different package, so
// the names the override rules see show which file they actually read.
function writeTwoLockfiles(manifest: Record<string, unknown> = {}) {
writeFileSync(join(dir, "package.json"), JSON.stringify({
name: "x",
overrides: { lodash: "4.17.21" },
...manifest,
}));
writeFileSync(join(dir, "package-lock.json"), JSON.stringify({
lockfileVersion: 3,
packages: { "": { name: "x" }, "node_modules/from-npm": { version: "1.0.0" } },
}));
writeFileSync(join(dir, "pnpm-lock.yaml"), [
"lockfileVersion: '9.0'",
"importers:",
" '.':",
" dependencies:",
" from-pnpm:",
" specifier: 1.0.0",
" version: 1.0.0",
"snapshots:",
" from-pnpm@1.0.0: {}",
].join("\n"));
}

beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "ctx-lockfile-select-"));
});
afterEach(() => rmSync(dir, { recursive: true, force: true }));

it("reads the lockfile the scan would choose when both npm and pnpm lockfiles exist", () => {
writeTwoLockfiles();
const ctx = build();
expect(ctx.packageManager).toBe("npm");
expect(ctx.lockfilePackageNames.has("from-npm")).toBe(true);
expect(ctx.lockfilePackageNames.has("from-pnpm")).toBe(false);
});

it("follows the packageManager field the way the scan does", () => {
writeTwoLockfiles({ packageManager: "pnpm@9.0.0" });
const ctx = build();
expect(ctx.packageManager).toBe("pnpm");
expect(ctx.lockfilePackageNames.has("from-pnpm")).toBe(true);
expect(ctx.lockfilePackageNames.has("from-npm")).toBe(false);
});

it("does not read a nested lockfile that belongs to another manifest", () => {
writeFileSync(join(dir, "package.json"), JSON.stringify({
name: "x",
overrides: { lodash: "4.17.21" },
}));
mkdirSync(join(dir, "frontend"));
writeFileSync(join(dir, "frontend", "package-lock.json"), JSON.stringify({
lockfileVersion: 3,
packages: { "": { name: "frontend" }, "node_modules/from-frontend": { version: "1.0.0" } },
}));
const ctx = build();
expect(ctx.packageManager).toBe("unknown");
expect(ctx.lockfilePackageNames.size).toBe(0);
});

it("treats a shrinkwrap-only project as npm and reads the shrinkwrap", () => {
writeFileSync(join(dir, "package.json"), JSON.stringify({
name: "x",
overrides: { lodash: "4.17.21" },
}));
writeFileSync(join(dir, "npm-shrinkwrap.json"), JSON.stringify({
lockfileVersion: 3,
packages: { "": { name: "x" }, "node_modules/from-shrinkwrap": { version: "1.0.0" } },
}));
const ctx = build();
expect(ctx.packageManager).toBe("npm");
expect(ctx.lockfilePackageNames.has("from-shrinkwrap")).toBe(true);
});
});
Loading