From 59b1c7c2b5ba1d45b2b7ab4e0c6aec8553279539 Mon Sep 17 00:00:00 2001 From: Sonu Kapoor Date: Wed, 4 Nov 2026 07:42:13 -0500 Subject: [PATCH 1/3] refactor(parsers): expose the scan's root lockfile selection Exports selectRootLockfile, which returns the lockfile loadPackages reads at a folder's root (packageManager field and shrinkwrap rules included), and readSelectedLockfile, which reads it with the scan's own handler. Lets other modules agree with the scan about which tree they are reading. --- src/parsers/index.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/parsers/index.ts b/src/parsers/index.ts index ec1861578..def416df8 100644 --- a/src/parsers/index.ts +++ b/src/parsers/index.ts @@ -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) { From 61bfda72a6ab126b970eb2629c125aeb4e88a09a Mon Sep 17 00:00:00 2001 From: Sonu Kapoor Date: Wed, 4 Nov 2026 07:47:38 -0500 Subject: [PATCH 2/3] fix(overrides): read the lockfile the scan selected The override context detected its own lockfile in pnpm, Yarn, Bun, npm order, while the scan reads bun.lock, npm-shrinkwrap.json, package-lock.json, pnpm-lock.yaml, yarn.lock and honours the packageManager field. With two managers' lockfiles in one folder the override rules read a different tree than the findings came from, so OA001 could call a working override orphaned and offer to remove it. The context now takes the scan's root lockfile and derives the package manager from it. A shrinkwrap-only project is now npm. A lockfile in a subfolder is not used, since it belongs to a different package.json. --- src/overrides/context-builder.ts | 82 +++++++++----------------------- 1 file changed, 23 insertions(+), 59 deletions(-) diff --git a/src/overrides/context-builder.ts b/src/overrides/context-builder.ts index 18f90b59b..8fa6d19e5 100644 --- a/src/overrides/context-builder.ts +++ b/src/overrides/context-builder.ts @@ -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 = @@ -37,9 +34,12 @@ export function buildOverrideContext( const raw = readFileSync(pkgJsonPath, "utf8"); const parsed = JSON.parse(raw) as Record; 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(); const lockfileVersions = readResult.kind === "ok" ? readResult.versions : new Map(); const nodeModulesExists = existsSync(join(projectPath, "node_modules")); @@ -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> = { + "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 @@ -159,52 +160,15 @@ function versionsByName(refs: { name: string; version: string }[]): Map 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( From d35a749e48265071c7959538149dc2630f7c5949 Mon Sep 17 00:00:00 2001 From: Sonu Kapoor Date: Wed, 4 Nov 2026 07:53:05 -0500 Subject: [PATCH 3/3] test(overrides): pin override lockfile selection to the scan's choice Covers npm and pnpm lockfiles side by side, the packageManager field choosing between them, a shrinkwrap-only project, and a nested lockfile being ignored. --- tests/overrides/context-builder.test.ts | 85 +++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/tests/overrides/context-builder.test.ts b/tests/overrides/context-builder.test.ts index e4e32ff2f..7b396f548 100644 --- a/tests/overrides/context-builder.test.ts +++ b/tests/overrides/context-builder.test.ts @@ -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 = {}) { + 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); + }); +});