From 96a8be1024596ac557432865d57a307daf8e3a33 Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Tue, 10 Mar 2026 15:53:06 +0530 Subject: [PATCH 01/10] fix: support npm install-strategy=linked (.store directory layout) --- src/applyPatches.ts | 71 +++++++++- src/makePatch.ts | 17 ++- src/resolvePackagePath.test.ts | 230 +++++++++++++++++++++++++++++++++ src/resolvePackagePath.ts | 140 ++++++++++++++++++++ src/stateFile.ts | 14 +- 5 files changed, 460 insertions(+), 12 deletions(-) create mode 100644 src/resolvePackagePath.test.ts create mode 100644 src/resolvePackagePath.ts diff --git a/src/applyPatches.ts b/src/applyPatches.ts index 1a50d094..67f0e4bb 100644 --- a/src/applyPatches.ts +++ b/src/applyPatches.ts @@ -8,10 +8,12 @@ import { logPatchSequenceError } from "./makePatch" import { PackageDetails, PatchedPackageDetails } from "./PackageDetails" import { packageIsDevDependency } from "./packageIsDevDependency" import { executeEffects } from "./patch/apply" +import { PatchFilePart } from "./patch/parse" import { readPatch } from "./patch/read" import { reversePatch } from "./patch/reverse" import { getGroupedPatches } from "./patchFs" import { join, relative } from "./path" +import { resolvePackagePath } from "./resolvePackagePath" import { clearPatchApplicationState, getPatchApplicationState, @@ -28,17 +30,19 @@ class PatchApplicationError extends Error { function getInstalledPackageVersion({ appPath, path, + resolvedPath, pathSpecifier, isDevOnly, patchFilename, }: { appPath: string path: string + resolvedPath: string pathSpecifier: string isDevOnly: boolean patchFilename: string }): null | string { - const packageDir = join(appPath, path) + const packageDir = join(appPath, resolvedPath) if (!existsSync(packageDir)) { if (process.env.NODE_ENV === "production" && isDevOnly) { return null @@ -47,13 +51,13 @@ function getInstalledPackageVersion({ let err = `${chalk.red("Error:")} Patch file found for package ${posix.basename( pathSpecifier, - )}` + ` which is not present at ${relative(".", packageDir)}` + )}` + ` which is not present at ${relative(".", join(appPath, path))}` if (!isDevOnly && process.env.NODE_ENV === "production") { err += ` If this package is a dev dependency, rename the patch file to - + ${chalk.bold(patchFilename.replace(".patch", ".dev.patch"))} ` } @@ -180,7 +184,20 @@ export function applyPatchesForPackage({ bestEffort: boolean }) { const pathSpecifier = patches[0].pathSpecifier - const state = patches.length > 1 ? getPatchApplicationState(patches[0]) : null + + // Resolve actual package path, handling npm install-strategy=linked (.store) + const firstPatch = patches[0] + const resolvedPackagePath = resolvePackagePath({ + appPath, + packagePath: firstPatch.path, + packageName: firstPatch.name, + version: firstPatch.version, + }) + + const state = + patches.length > 1 + ? getPatchApplicationState(patches[0], resolvedPackagePath) + : null const unappliedPatches = patches.slice(0) const appliedPatches: PatchedPackageDetails[] = [] // if there are multiple patches to apply, we can't rely on the reverse-patch-dry-run behavior to make this operation @@ -232,9 +249,18 @@ export function applyPatchesForPackage({ try { const { name, version, path, isDevOnly, patchFilename } = patchDetails + // Resolve the actual package path, handling npm install-strategy=linked + const resolvedPath = resolvePackagePath({ + appPath, + packagePath: path, + packageName: name, + version, + }) + const installedPackageVersion = getInstalledPackageVersion({ appPath, path, + resolvedPath, pathSpecifier, isDevOnly: isDevOnly || @@ -264,6 +290,7 @@ export function applyPatchesForPackage({ patchDir, cwd: process.cwd(), bestEffort, + resolvedPath, }) ) { appliedPatches.push(patchDetails) @@ -338,7 +365,7 @@ export function applyPatchesForPackage({ } // if we removed all the patches that were previously applied we can delete the state file if (appliedPatches.length === patches.length) { - clearPatchApplicationState(patches[0]) + clearPatchApplicationState(patches[0], resolvedPackagePath) } else { // We failed while reversing patches and some are still in the applied state. // We need to update the state file to reflect that. @@ -364,6 +391,7 @@ export function applyPatchesForPackage({ patchFilename: patch.patchFilename, })), isRebasing: false, + resolvedPath: resolvedPackagePath, }) } } else { @@ -390,6 +418,7 @@ export function applyPatchesForPackage({ packageDetails: patches[0], patches: nextState, isRebasing: !!failedPatch, + resolvedPath: resolvedPackagePath, }) } if (failedPatch) { @@ -405,6 +434,7 @@ export function applyPatch({ patchDir, cwd, bestEffort, + resolvedPath, }: { patchFilePath: string reverse: boolean @@ -412,6 +442,7 @@ export function applyPatch({ patchDir: string cwd: string bestEffort: boolean + resolvedPath?: string }): boolean { const patch = readPatch({ patchFilePath, @@ -419,6 +450,11 @@ export function applyPatch({ patchDir, }) + // Remap paths if package resolved to a different location (e.g. .store) + if (resolvedPath && resolvedPath !== patchDetails.path) { + remapPatchPaths(patch, patchDetails.path, resolvedPath) + } + const forward = reverse ? reversePatch(patch) : patch try { if (!bestEffort) { @@ -450,6 +486,31 @@ export function applyPatch({ return true } +/** + * Remaps paths in parsed patch effects when the package resolved to a + * different location than expected (e.g. npm install-strategy=linked .store). + */ +function remapPatchPaths( + patch: PatchFilePart[], + originalPrefix: string, + newPrefix: string, +): void { + for (const part of patch) { + switch (part.type) { + case "patch": + case "file deletion": + case "file creation": + case "mode change": + part.path = part.path.replace(originalPrefix, newPrefix) + break + case "rename": + part.fromPath = part.fromPath.replace(originalPrefix, newPrefix) + part.toPath = part.toPath.replace(originalPrefix, newPrefix) + break + } + } +} + function createVersionMismatchWarning({ packageName, actualVersion, diff --git a/src/makePatch.ts b/src/makePatch.ts index 7e008eb3..4df4a348 100644 --- a/src/makePatch.ts +++ b/src/makePatch.ts @@ -32,6 +32,7 @@ import { import { parsePatchFile } from "./patch/parse" import { getGroupedPatches } from "./patchFs" import { dirname, join, resolve } from "./path" +import { resolvePackagePath } from "./resolvePackagePath" import { resolveRelativeFileDependencies } from "./resolveRelativeFileDependencies" import { spawnSafeSync } from "./spawnSafe" import { @@ -145,11 +146,21 @@ export function makePatch({ mode.type !== "append" const appPackageJson = require(join(appPath, "package.json")) - const packagePath = join(appPath, packageDetails.path) + + // Resolve actual package path, handling npm install-strategy=linked (.store) + const resolvedPackageDetails = resolvePackagePath({ + appPath, + packagePath: packageDetails.path, + packageName: packageDetails.name, + }) + const packagePath = join(appPath, resolvedPackageDetails) const packageJsonPath = join(packagePath, "package.json") if (!existsSync(packageJsonPath)) { - printNoPackageFoundError(packagePathSpecifier, packageJsonPath) + printNoPackageFoundError( + packagePathSpecifier, + join(appPath, packageDetails.path, "package.json"), + ) process.exit(1) } @@ -187,7 +198,7 @@ export function makePatch({ ) const packageVersion = getPackageVersion( - join(resolve(packageDetails.path), "package.json"), + join(resolve(resolvedPackageDetails), "package.json"), ) // copy .npmrc/.yarnrc in case packages are hosted in private registry diff --git a/src/resolvePackagePath.test.ts b/src/resolvePackagePath.test.ts new file mode 100644 index 00000000..12936d22 --- /dev/null +++ b/src/resolvePackagePath.test.ts @@ -0,0 +1,230 @@ +import { resolvePackagePath } from "./resolvePackagePath" +import { mkdirpSync, writeFileSync } from "fs-extra" +import { join } from "./path" +import { dirSync } from "tmp" + +describe("resolvePackagePath", () => { + let tmpDir: string + let cleanup: () => void + + beforeEach(() => { + const tmp = dirSync({ unsafeCleanup: true }) + tmpDir = tmp.name + cleanup = tmp.removeCallback + }) + + afterEach(() => { + cleanup() + }) + + it("returns the original path when the package exists at the expected location", () => { + const pkgDir = join(tmpDir, "node_modules", "some-package") + mkdirpSync(pkgDir) + writeFileSync(join(pkgDir, "package.json"), '{"version":"1.0.0"}') + + const result = resolvePackagePath({ + appPath: tmpDir, + packagePath: "node_modules/some-package", + packageName: "some-package", + }) + + expect(result).toBe("node_modules/some-package") + }) + + it("resolves from .store when the expected path does not exist", () => { + const storeDir = join( + tmpDir, + "node_modules", + ".store", + "some-package@1.0.0-abc123", + "node_modules", + "some-package", + ) + mkdirpSync(storeDir) + writeFileSync(join(storeDir, "package.json"), '{"version":"1.0.0"}') + + const result = resolvePackagePath({ + appPath: tmpDir, + packagePath: "node_modules/some-package", + packageName: "some-package", + }) + + expect(result).toBe( + "node_modules/.store/some-package@1.0.0-abc123/node_modules/some-package", + ) + }) + + it("prefers version-specific match in .store", () => { + // Create two versions in .store + const storeDir1 = join( + tmpDir, + "node_modules", + ".store", + "pkg@1.0.0-hash1", + "node_modules", + "pkg", + ) + const storeDir2 = join( + tmpDir, + "node_modules", + ".store", + "pkg@2.0.0-hash2", + "node_modules", + "pkg", + ) + mkdirpSync(storeDir1) + mkdirpSync(storeDir2) + writeFileSync(join(storeDir1, "package.json"), '{"version":"1.0.0"}') + writeFileSync(join(storeDir2, "package.json"), '{"version":"2.0.0"}') + + const result = resolvePackagePath({ + appPath: tmpDir, + packagePath: "node_modules/pkg", + packageName: "pkg", + version: "2.0.0", + }) + + expect(result).toBe("node_modules/.store/pkg@2.0.0-hash2/node_modules/pkg") + }) + + it("handles scoped packages in .store", () => { + const storeDir = join( + tmpDir, + "node_modules", + ".store", + "@scope+pkg@1.0.0-hash", + "node_modules", + "@scope", + "pkg", + ) + mkdirpSync(storeDir) + writeFileSync(join(storeDir, "package.json"), '{"version":"1.0.0"}') + + const result = resolvePackagePath({ + appPath: tmpDir, + packagePath: "node_modules/@scope/pkg", + packageName: "@scope/pkg", + version: "1.0.0", + }) + + expect(result).toBe( + "node_modules/.store/@scope+pkg@1.0.0-hash/node_modules/@scope/pkg", + ) + }) + + it("returns original path when package is not found anywhere", () => { + const result = resolvePackagePath({ + appPath: tmpDir, + packagePath: "node_modules/nonexistent", + packageName: "nonexistent", + }) + + expect(result).toBe("node_modules/nonexistent") + }) + + it("returns original path when .store directory does not exist", () => { + const result = resolvePackagePath({ + appPath: tmpDir, + packagePath: "node_modules/some-package", + packageName: "some-package", + }) + + expect(result).toBe("node_modules/some-package") + }) + + it("resolves nested package from .store", () => { + // Simulate: parent exists but nested dep doesn't at expected path + const parentDir = join(tmpDir, "node_modules", "parent") + mkdirpSync(parentDir) + + // The nested dep is in .store + const storeDir = join( + tmpDir, + "node_modules", + ".store", + "child@1.0.0-hash", + "node_modules", + "child", + ) + mkdirpSync(storeDir) + writeFileSync(join(storeDir, "package.json"), '{"version":"1.0.0"}') + + const result = resolvePackagePath({ + appPath: tmpDir, + packagePath: "node_modules/parent/node_modules/child", + packageName: "child", + version: "1.0.0", + }) + + expect(result).toBe( + "node_modules/.store/child@1.0.0-hash/node_modules/child", + ) + }) + + it("resolves from ancestor node_modules (monorepo hoisting)", () => { + // Simulate monorepo: appPath is packages/a, package is hoisted to root + const workspaceDir = join(tmpDir, "packages", "a") + mkdirpSync(workspaceDir) + + // Package is hoisted to root node_modules + const hoistedDir = join(tmpDir, "node_modules", "foo") + mkdirpSync(hoistedDir) + writeFileSync(join(hoistedDir, "package.json"), '{"version":"1.0.0"}') + + const result = resolvePackagePath({ + appPath: workspaceDir, + packagePath: "node_modules/foo", + packageName: "foo", + }) + + expect(result).toBe("../../node_modules/foo") + }) + + it("resolves scoped package from ancestor node_modules", () => { + const workspaceDir = join(tmpDir, "packages", "a") + mkdirpSync(workspaceDir) + + const hoistedDir = join(tmpDir, "node_modules", "@scope", "bar") + mkdirpSync(hoistedDir) + writeFileSync(join(hoistedDir, "package.json"), '{"version":"2.0.0"}') + + const result = resolvePackagePath({ + appPath: workspaceDir, + packagePath: "node_modules/@scope/bar", + packageName: "@scope/bar", + }) + + expect(result).toBe("../../node_modules/@scope/bar") + }) + + it("prefers .store over ancestor node_modules", () => { + const workspaceDir = join(tmpDir, "packages", "a") + mkdirpSync(workspaceDir) + + // Package in .store under workspace + const storeDir = join( + workspaceDir, + "node_modules", + ".store", + "foo@1.0.0-hash", + "node_modules", + "foo", + ) + mkdirpSync(storeDir) + writeFileSync(join(storeDir, "package.json"), '{"version":"1.0.0"}') + + // Also in ancestor node_modules + const hoistedDir = join(tmpDir, "node_modules", "foo") + mkdirpSync(hoistedDir) + writeFileSync(join(hoistedDir, "package.json"), '{"version":"1.0.0"}') + + const result = resolvePackagePath({ + appPath: workspaceDir, + packagePath: "node_modules/foo", + packageName: "foo", + version: "1.0.0", + }) + + expect(result).toBe("node_modules/.store/foo@1.0.0-hash/node_modules/foo") + }) +}) diff --git a/src/resolvePackagePath.ts b/src/resolvePackagePath.ts new file mode 100644 index 00000000..9e5459a7 --- /dev/null +++ b/src/resolvePackagePath.ts @@ -0,0 +1,140 @@ +import { existsSync, readdirSync } from "fs-extra" +import { join, relative, resolve } from "./path" + +/** + * Resolves the real filesystem path for a package that may not be at the + * expected node_modules location. Handles: + * + * 1. npm install-strategy=linked (.store directory layout) + * 2. Monorepo hoisting (package in ancestor node_modules) + * + * Returns a path relative to appPath (like packageDetails.path). + * Falls back to the original packagePath if the package cannot be found elsewhere. + */ +export function resolvePackagePath({ + appPath, + packagePath, + packageName, + version, +}: { + appPath: string + packagePath: string + packageName: string + version?: string +}): string { + const fullPath = join(appPath, packagePath) + + // Direct path exists (handles regular installs and working symlinks) + if (existsSync(fullPath)) { + return packagePath + } + + // Try to find the package in .store directory (npm install-strategy=linked) + const storePath = resolveFromStore({ appPath, packageName, version }) + if (storePath) { + return storePath + } + + // Try to find the package in ancestor node_modules (monorepo hoisting) + const ancestorPath = resolveFromAncestors({ appPath, packageName }) + if (ancestorPath) { + return ancestorPath + } + + // Couldn't resolve, return original (caller will handle the error) + return packagePath +} + +function resolveFromStore({ + appPath, + packageName, + version, +}: { + appPath: string + packageName: string + version?: string +}): string | null { + const storePath = join(appPath, "node_modules", ".store") + + if (!existsSync(storePath)) { + return null + } + + try { + const storeEntries = readdirSync(storePath) + // For scoped packages like @scope/name, .store uses + as separator + const normalizedName = packageName.replace("/", "+") + + // Try version-specific match first + if (version) { + const versionPrefix = `${normalizedName}@${version}` + for (const entry of storeEntries) { + if (entry.startsWith(versionPrefix)) { + const candidatePath = join( + "node_modules", + ".store", + entry, + "node_modules", + packageName, + ) + if (existsSync(join(appPath, candidatePath))) { + return candidatePath + } + } + } + } + + // Try any version match + const namePrefix = normalizedName + "@" + for (const entry of storeEntries) { + if (entry.startsWith(namePrefix)) { + const candidatePath = join( + "node_modules", + ".store", + entry, + "node_modules", + packageName, + ) + if (existsSync(join(appPath, candidatePath))) { + return candidatePath + } + } + } + } catch (e) { + // noop + } + + return null +} + +/** + * Walks up parent directories looking for the package in ancestor + * node_modules directories. This handles monorepo hoisting where + * dependencies are installed in the workspace root node_modules + * rather than the package's own node_modules. + */ +function resolveFromAncestors({ + appPath, + packageName, +}: { + appPath: string + packageName: string +}): string | null { + let currentDir = resolve(appPath, "..") + + while (true) { + const candidateFullPath = join(currentDir, "node_modules", packageName) + if (existsSync(candidateFullPath)) { + return relative(appPath, candidateFullPath) + } + + const parentDir = resolve(currentDir, "..") + if (parentDir === currentDir) { + // Reached filesystem root + break + } + currentDir = parentDir + } + + return null +} diff --git a/src/stateFile.ts b/src/stateFile.ts index 7aea576d..5037a718 100644 --- a/src/stateFile.ts +++ b/src/stateFile.ts @@ -21,8 +21,9 @@ export const STATE_FILE_NAME = ".patch-package.json" export function getPatchApplicationState( packageDetails: PackageDetails, + resolvedPath?: string, ): PatchApplicationState | null { - const fileName = join(packageDetails.path, STATE_FILE_NAME) + const fileName = join(resolvedPath || packageDetails.path, STATE_FILE_NAME) let state: null | PatchApplicationState = null try { @@ -46,12 +47,14 @@ export function savePatchApplicationState({ packageDetails, patches, isRebasing, + resolvedPath, }: { packageDetails: PackageDetails patches: PatchState[] isRebasing: boolean + resolvedPath?: string }) { - const fileName = join(packageDetails.path, STATE_FILE_NAME) + const fileName = join(resolvedPath || packageDetails.path, STATE_FILE_NAME) const state: PatchApplicationState = { patches, @@ -62,8 +65,11 @@ export function savePatchApplicationState({ writeFileSync(fileName, stringify(state, { space: 4 }), "utf8") } -export function clearPatchApplicationState(packageDetails: PackageDetails) { - const fileName = join(packageDetails.path, STATE_FILE_NAME) +export function clearPatchApplicationState( + packageDetails: PackageDetails, + resolvedPath?: string, +) { + const fileName = join(resolvedPath || packageDetails.path, STATE_FILE_NAME) try { unlinkSync(fileName) From 32b93368dde71f1ea1bf34433ce5d7e36329939b Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Mon, 14 Sep 2026 14:55:07 +0530 Subject: [PATCH 02/10] fix: resolve packages from npm's actual .store layout Scoped packages live under .store/@scope/, and nested lockfile paths do not always match the real dependent, so pick the store entry by lockfile version instead of the first name match. Drop the name-only ancestor node_modules fallback, which could patch an unrelated copy. --- src/applyPatches.ts | 31 ++-- src/makePatch.ts | 12 +- src/resolvePackagePath.test.ts | 320 +++++++++++++++------------------ src/resolvePackagePath.ts | 250 ++++++++++++++++---------- 4 files changed, 316 insertions(+), 297 deletions(-) diff --git a/src/applyPatches.ts b/src/applyPatches.ts index 67f0e4bb..9e2150b3 100644 --- a/src/applyPatches.ts +++ b/src/applyPatches.ts @@ -184,14 +184,10 @@ export function applyPatchesForPackage({ bestEffort: boolean }) { const pathSpecifier = patches[0].pathSpecifier - - // Resolve actual package path, handling npm install-strategy=linked (.store) - const firstPatch = patches[0] const resolvedPackagePath = resolvePackagePath({ appPath, - packagePath: firstPatch.path, - packageName: firstPatch.name, - version: firstPatch.version, + packageDetails: patches[0], + version: patches[0].version, }) const state = @@ -249,18 +245,10 @@ export function applyPatchesForPackage({ try { const { name, version, path, isDevOnly, patchFilename } = patchDetails - // Resolve the actual package path, handling npm install-strategy=linked - const resolvedPath = resolvePackagePath({ - appPath, - packagePath: path, - packageName: name, - version, - }) - const installedPackageVersion = getInstalledPackageVersion({ appPath, path, - resolvedPath, + resolvedPath: resolvedPackagePath, pathSpecifier, isDevOnly: isDevOnly || @@ -290,7 +278,7 @@ export function applyPatchesForPackage({ patchDir, cwd: process.cwd(), bestEffort, - resolvedPath, + resolvedPath: resolvedPackagePath, }) ) { appliedPatches.push(patchDetails) @@ -495,17 +483,22 @@ function remapPatchPaths( originalPrefix: string, newPrefix: string, ): void { + const remap = (path: string) => + path.startsWith(`${originalPrefix}/`) + ? newPrefix + path.slice(originalPrefix.length) + : path + for (const part of patch) { switch (part.type) { case "patch": case "file deletion": case "file creation": case "mode change": - part.path = part.path.replace(originalPrefix, newPrefix) + part.path = remap(part.path) break case "rename": - part.fromPath = part.fromPath.replace(originalPrefix, newPrefix) - part.toPath = part.toPath.replace(originalPrefix, newPrefix) + part.fromPath = remap(part.fromPath) + part.toPath = remap(part.toPath) break } } diff --git a/src/makePatch.ts b/src/makePatch.ts index 4df4a348..f20e26bd 100644 --- a/src/makePatch.ts +++ b/src/makePatch.ts @@ -81,6 +81,7 @@ export function makePatch({ return } + const resolvedPackagePath = resolvePackagePath({ appPath, packageDetails }) const state = getPatchApplicationState(packageDetails) const isRebasing = state?.isRebasing ?? false @@ -146,14 +147,7 @@ export function makePatch({ mode.type !== "append" const appPackageJson = require(join(appPath, "package.json")) - - // Resolve actual package path, handling npm install-strategy=linked (.store) - const resolvedPackageDetails = resolvePackagePath({ - appPath, - packagePath: packageDetails.path, - packageName: packageDetails.name, - }) - const packagePath = join(appPath, resolvedPackageDetails) + const packagePath = join(appPath, resolvedPackagePath) const packageJsonPath = join(packagePath, "package.json") if (!existsSync(packageJsonPath)) { @@ -198,7 +192,7 @@ export function makePatch({ ) const packageVersion = getPackageVersion( - join(resolve(resolvedPackageDetails), "package.json"), + join(resolve(resolvedPackagePath), "package.json"), ) // copy .npmrc/.yarnrc in case packages are hosted in private registry diff --git a/src/resolvePackagePath.test.ts b/src/resolvePackagePath.test.ts index 12936d22..a17db473 100644 --- a/src/resolvePackagePath.test.ts +++ b/src/resolvePackagePath.test.ts @@ -1,15 +1,17 @@ -import { resolvePackagePath } from "./resolvePackagePath" -import { mkdirpSync, writeFileSync } from "fs-extra" -import { join } from "./path" +import { mkdirpSync, realpathSync, symlinkSync, writeFileSync } from "fs-extra" +import { dirname, relative } from "path" import { dirSync } from "tmp" +import { getPatchDetailsFromCliString } from "./PackageDetails" +import { join } from "./path" +import { resolvePackagePath } from "./resolvePackagePath" describe("resolvePackagePath", () => { - let tmpDir: string + let appPath: string let cleanup: () => void beforeEach(() => { const tmp = dirSync({ unsafeCleanup: true }) - tmpDir = tmp.name + appPath = realpathSync(tmp.name) cleanup = tmp.removeCallback }) @@ -17,214 +19,182 @@ describe("resolvePackagePath", () => { cleanup() }) - it("returns the original path when the package exists at the expected location", () => { - const pkgDir = join(tmpDir, "node_modules", "some-package") - mkdirpSync(pkgDir) - writeFileSync(join(pkgDir, "package.json"), '{"version":"1.0.0"}') + function addStoreEntry(entry: string, name: string, version: string) { + const packageDir = join( + appPath, + "node_modules/.store", + entry, + "node_modules", + name, + ) + mkdirpSync(packageDir) + writeFileSync(join(packageDir, "package.json"), JSON.stringify({ version })) + return join("node_modules/.store", entry, "node_modules", name) + } + + function link(from: string, to: string) { + const fromPath = join(appPath, from) + mkdirpSync(dirname(fromPath)) + symlinkSync(relative(dirname(fromPath), join(appPath, to)), fromPath) + } + + function writeLockfile(packages: Record) { + writeFileSync( + join(appPath, "package-lock.json"), + JSON.stringify({ lockfileVersion: 3, packages }), + ) + } - const result = resolvePackagePath({ - appPath: tmpDir, - packagePath: "node_modules/some-package", - packageName: "some-package", + function resolve(pathSpecifier: string, version?: string) { + return resolvePackagePath({ + appPath, + packageDetails: getPatchDetailsFromCliString(pathSpecifier)!, + version, }) + } - expect(result).toBe("node_modules/some-package") + it("returns the original path when the package exists", () => { + mkdirpSync(join(appPath, "node_modules/some-package")) + addStoreEntry("some-package@1.0.0-hash", "some-package", "1.0.0") + + expect(resolve("some-package")).toBe("node_modules/some-package") }) - it("resolves from .store when the expected path does not exist", () => { - const storeDir = join( - tmpDir, - "node_modules", - ".store", - "some-package@1.0.0-abc123", - "node_modules", - "some-package", - ) - mkdirpSync(storeDir) - writeFileSync(join(storeDir, "package.json"), '{"version":"1.0.0"}') + it("returns the original path when there is no .store directory", () => { + expect(resolve("some-package")).toBe("node_modules/some-package") + }) - const result = resolvePackagePath({ - appPath: tmpDir, - packagePath: "node_modules/some-package", - packageName: "some-package", - }) + it("returns the original path when the package is not in .store", () => { + addStoreEntry("other@1.0.0-hash", "other", "1.0.0") - expect(result).toBe( - "node_modules/.store/some-package@1.0.0-abc123/node_modules/some-package", - ) + expect(resolve("some-package")).toBe("node_modules/some-package") }) - it("prefers version-specific match in .store", () => { - // Create two versions in .store - const storeDir1 = join( - tmpDir, - "node_modules", - ".store", - "pkg@1.0.0-hash1", - "node_modules", - "pkg", - ) - const storeDir2 = join( - tmpDir, - "node_modules", - ".store", - "pkg@2.0.0-hash2", - "node_modules", - "pkg", - ) - mkdirpSync(storeDir1) - mkdirpSync(storeDir2) - writeFileSync(join(storeDir1, "package.json"), '{"version":"1.0.0"}') - writeFileSync(join(storeDir2, "package.json"), '{"version":"2.0.0"}') - - const result = resolvePackagePath({ - appPath: tmpDir, - packagePath: "node_modules/pkg", - packageName: "pkg", - version: "2.0.0", - }) + it("resolves a transitive package from .store", () => { + const storePath = addStoreEntry("ms@2.1.2-hash", "ms", "2.1.2") - expect(result).toBe("node_modules/.store/pkg@2.0.0-hash2/node_modules/pkg") + expect(resolve("ms")).toBe(storePath) }) - it("handles scoped packages in .store", () => { - const storeDir = join( - tmpDir, - "node_modules", - ".store", - "@scope+pkg@1.0.0-hash", - "node_modules", - "@scope", - "pkg", - ) - mkdirpSync(storeDir) - writeFileSync(join(storeDir, "package.json"), '{"version":"1.0.0"}') - - const result = resolvePackagePath({ - appPath: tmpDir, - packagePath: "node_modules/@scope/pkg", - packageName: "@scope/pkg", - version: "1.0.0", - }) + it("does not match packages sharing a name prefix", () => { + addStoreEntry("ms-extra@1.0.0-hash", "ms-extra", "1.0.0") - expect(result).toBe( - "node_modules/.store/@scope+pkg@1.0.0-hash/node_modules/@scope/pkg", - ) + expect(resolve("ms")).toBe("node_modules/ms") }) - it("returns original path when package is not found anywhere", () => { - const result = resolvePackagePath({ - appPath: tmpDir, - packagePath: "node_modules/nonexistent", - packageName: "nonexistent", - }) + it("resolves a scoped transitive package from .store", () => { + const storePath = addStoreEntry( + "@babel/highlight@7.25.9-hash", + "@babel/highlight", + "7.25.9", + ) - expect(result).toBe("node_modules/nonexistent") + expect(resolve("@babel/highlight")).toBe(storePath) }) - it("returns original path when .store directory does not exist", () => { - const result = resolvePackagePath({ - appPath: tmpDir, - packagePath: "node_modules/some-package", - packageName: "some-package", - }) + it("picks the lockfile version when .store has several versions", () => { + addStoreEntry("ms@2.0.0-hash", "ms", "2.0.0") + const storePath = addStoreEntry("ms@2.1.2-hash", "ms", "2.1.2") + writeLockfile({ "node_modules/ms": { version: "2.1.2" } }) - expect(result).toBe("node_modules/some-package") + expect(resolve("ms")).toBe(storePath) + expect(resolve("ms", "2.0.0")).toBe(storePath) }) - it("resolves nested package from .store", () => { - // Simulate: parent exists but nested dep doesn't at expected path - const parentDir = join(tmpDir, "node_modules", "parent") - mkdirpSync(parentDir) + it("falls back to the given version without a lockfile entry", () => { + addStoreEntry("ms@2.0.0-hash", "ms", "2.0.0") + const storePath = addStoreEntry("ms@2.1.2-hash", "ms", "2.1.2") - // The nested dep is in .store - const storeDir = join( - tmpDir, - "node_modules", - ".store", - "child@1.0.0-hash", - "node_modules", - "child", - ) - mkdirpSync(storeDir) - writeFileSync(join(storeDir, "package.json"), '{"version":"1.0.0"}') - - const result = resolvePackagePath({ - appPath: tmpDir, - packagePath: "node_modules/parent/node_modules/child", - packageName: "child", - version: "1.0.0", - }) + expect(resolve("ms", "2.1.2")).toBe(storePath) + }) - expect(result).toBe( - "node_modules/.store/child@1.0.0-hash/node_modules/child", - ) + it("does not guess between several versions", () => { + addStoreEntry("ms@2.0.0-hash", "ms", "2.0.0") + addStoreEntry("ms@2.1.2-hash", "ms", "2.1.2") + + expect(resolve("ms")).toBe("node_modules/ms") + expect(resolve("ms", "3.0.0")).toBe("node_modules/ms") }) - it("resolves from ancestor node_modules (monorepo hoisting)", () => { - // Simulate monorepo: appPath is packages/a, package is hoisted to root - const workspaceDir = join(tmpDir, "packages", "a") - mkdirpSync(workspaceDir) + it("resolves a nested package through its parent's store entry", () => { + const parentPath = addStoreEntry( + "finalhandler@1.2.0-hash", + "finalhandler", + "1.2.0", + ) + addStoreEntry("ms@2.1.2-hash", "ms", "2.1.2") + const nestedPath = addStoreEntry("ms@2.0.0-hash", "ms", "2.0.0") + link("node_modules/finalhandler", parentPath) + link( + "node_modules/.store/finalhandler@1.2.0-hash/node_modules/ms", + nestedPath, + ) - // Package is hoisted to root node_modules - const hoistedDir = join(tmpDir, "node_modules", "foo") - mkdirpSync(hoistedDir) - writeFileSync(join(hoistedDir, "package.json"), '{"version":"1.0.0"}') + expect(resolve("finalhandler/ms")).toBe(nestedPath) + }) - const result = resolvePackagePath({ - appPath: workspaceDir, - packagePath: "node_modules/foo", - packageName: "foo", + it("resolves a nested package whose lockfile parent is not its dependent", () => { + const parentPath = addStoreEntry( + "finalhandler@1.2.0-hash", + "finalhandler", + "1.2.0", + ) + const directPath = addStoreEntry("ms@2.1.2-hash", "ms", "2.1.2") + const nestedPath = addStoreEntry("ms@2.0.0-hash", "ms", "2.0.0") + link("node_modules/finalhandler", parentPath) + link("node_modules/ms", directPath) + writeLockfile({ + "node_modules/ms": { version: "2.1.2" }, + "node_modules/finalhandler/node_modules/ms": { version: "2.0.0" }, }) - expect(result).toBe("../../node_modules/foo") + expect(resolve("finalhandler/ms")).toBe(nestedPath) }) - it("resolves scoped package from ancestor node_modules", () => { - const workspaceDir = join(tmpDir, "packages", "a") - mkdirpSync(workspaceDir) + it("does not substitute another version for the lockfile version", () => { + addStoreEntry("ms@2.0.0-hash", "ms", "2.0.0") + writeLockfile({ "node_modules/ms": { version: "2.1.2" } }) - const hoistedDir = join(tmpDir, "node_modules", "@scope", "bar") - mkdirpSync(hoistedDir) - writeFileSync(join(hoistedDir, "package.json"), '{"version":"2.0.0"}') + expect(resolve("ms", "2.0.0")).toBe("node_modules/ms") + }) - const result = resolvePackagePath({ - appPath: workspaceDir, - packagePath: "node_modules/@scope/bar", - packageName: "@scope/bar", - }) + it("resolves a scoped nested package through a scoped parent", () => { + const parentPath = addStoreEntry( + "@babel/code-frame@7.24.7-hash", + "@babel/code-frame", + "7.24.7", + ) + const nestedPath = addStoreEntry( + "@babel/highlight@7.25.9-hash", + "@babel/highlight", + "7.25.9", + ) + link("node_modules/@babel/code-frame", parentPath) + link( + "node_modules/.store/@babel/code-frame@7.24.7-hash/node_modules/@babel/highlight", + nestedPath, + ) - expect(result).toBe("../../node_modules/@scope/bar") + expect(resolve("@babel/code-frame/@babel/highlight")).toBe(nestedPath) }) - it("prefers .store over ancestor node_modules", () => { - const workspaceDir = join(tmpDir, "packages", "a") - mkdirpSync(workspaceDir) + it("returns the original path when a nested package is missing", () => { + const parentPath = addStoreEntry("parent@1.0.0-hash", "parent", "1.0.0") + link("node_modules/parent", parentPath) - // Package in .store under workspace - const storeDir = join( - workspaceDir, - "node_modules", - ".store", - "foo@1.0.0-hash", - "node_modules", - "foo", + expect(resolve("parent/child")).toBe( + "node_modules/parent/node_modules/child", ) - mkdirpSync(storeDir) - writeFileSync(join(storeDir, "package.json"), '{"version":"1.0.0"}') - - // Also in ancestor node_modules - const hoistedDir = join(tmpDir, "node_modules", "foo") - mkdirpSync(hoistedDir) - writeFileSync(join(hoistedDir, "package.json"), '{"version":"1.0.0"}') - - const result = resolvePackagePath({ - appPath: workspaceDir, - packagePath: "node_modules/foo", - packageName: "foo", - version: "1.0.0", - }) + }) + + it("does not resolve nested packages outside appPath", () => { + appPath = join(appPath, "app") + const parentPath = addStoreEntry("parent@1.0.0-hash", "parent", "1.0.0") + link("node_modules/parent", parentPath) + mkdirpSync(join(appPath, "../node_modules/child")) - expect(result).toBe("node_modules/.store/foo@1.0.0-hash/node_modules/foo") + expect(resolve("parent/child")).toBe( + "node_modules/parent/node_modules/child", + ) }) }) diff --git a/src/resolvePackagePath.ts b/src/resolvePackagePath.ts index 9e5459a7..d32564f6 100644 --- a/src/resolvePackagePath.ts +++ b/src/resolvePackagePath.ts @@ -1,136 +1,173 @@ -import { existsSync, readdirSync } from "fs-extra" -import { join, relative, resolve } from "./path" +import { existsSync, readdirSync, readFileSync, realpathSync } from "fs-extra" +import { basename, dirname } from "path" +import { PackageDetails } from "./PackageDetails" +import { join, relative } from "./path" /** - * Resolves the real filesystem path for a package that may not be at the - * expected node_modules location. Handles: - * - * 1. npm install-strategy=linked (.store directory layout) - * 2. Monorepo hoisting (package in ancestor node_modules) - * - * Returns a path relative to appPath (like packageDetails.path). - * Falls back to the original packagePath if the package cannot be found elsewhere. + * npm install-strategy=linked keeps transitive and nested packages only in + * node_modules/.store. Returns packageDetails.path when it exists or can't be resolved. */ export function resolvePackagePath({ appPath, - packagePath, - packageName, + packageDetails, version, }: { appPath: string - packagePath: string - packageName: string + packageDetails: PackageDetails version?: string }): string { - const fullPath = join(appPath, packagePath) + const { path, packageNames, name } = packageDetails - // Direct path exists (handles regular installs and working symlinks) - if (existsSync(fullPath)) { - return packagePath + if (existsSync(join(appPath, path))) { + return path } - // Try to find the package in .store directory (npm install-strategy=linked) - const storePath = resolveFromStore({ appPath, packageName, version }) - if (storePath) { - return storePath + const storeDir = join(appPath, "node_modules", ".store") + if (!existsSync(storeDir)) { + return path } - // Try to find the package in ancestor node_modules (monorepo hoisting) - const ancestorPath = resolveFromAncestors({ appPath, packageName }) - if (ancestorPath) { - return ancestorPath - } + const appRealPath = realpathSync(appPath) + const lockfilePackages = readLockfilePackages(appPath) + const lockfileVersion = lockfilePackages[path]?.version + + const packageDir = + resolveThroughParents({ + appPath, + appRealPath, + storeDir, + packageNames, + lockfilePackages, + lockfileVersion, + }) || + findInStore({ + storeDir, + name, + lockfileVersion, + fallbackVersion: version, + }) - // Couldn't resolve, return original (caller will handle the error) - return packagePath + return packageDir ? relative(appRealPath, packageDir) : path } -function resolveFromStore({ +/** + * The lockfile nests packages as a hoisted install would, which may not match + * the real dependent, so the result only counts if its version agrees. + */ +function resolveThroughParents({ appPath, - packageName, - version, + appRealPath, + storeDir, + packageNames, + lockfilePackages, + lockfileVersion, }: { appPath: string - packageName: string - version?: string + appRealPath: string + storeDir: string + packageNames: string[] + lockfilePackages: LockfilePackages + lockfileVersion?: string }): string | null { - const storePath = join(appPath, "node_modules", ".store") - - if (!existsSync(storePath)) { + const [rootName, ...nestedNames] = packageNames + if (!nestedNames.length) { return null } - try { - const storeEntries = readdirSync(storePath) - // For scoped packages like @scope/name, .store uses + as separator - const normalizedName = packageName.replace("/", "+") - - // Try version-specific match first - if (version) { - const versionPrefix = `${normalizedName}@${version}` - for (const entry of storeEntries) { - if (entry.startsWith(versionPrefix)) { - const candidatePath = join( - "node_modules", - ".store", - entry, - "node_modules", - packageName, - ) - if (existsSync(join(appPath, candidatePath))) { - return candidatePath - } - } - } - } + const rootPath = join("node_modules", rootName) + let packageDir = existsSync(join(appPath, rootPath)) + ? realpathSync(join(appPath, rootPath)) + : findInStore({ + storeDir, + name: rootName, + lockfileVersion: lockfilePackages[rootPath]?.version, + }) - // Try any version match - const namePrefix = normalizedName + "@" - for (const entry of storeEntries) { - if (entry.startsWith(namePrefix)) { - const candidatePath = join( - "node_modules", - ".store", - entry, - "node_modules", - packageName, - ) - if (existsSync(join(appPath, candidatePath))) { - return candidatePath - } - } + for (const name of nestedNames) { + if (!packageDir) { + return null } - } catch (e) { - // noop + packageDir = resolveDependency({ fromDir: packageDir, name, appRealPath }) } - return null + if ( + packageDir && + lockfileVersion && + readPackageVersion(packageDir) !== lockfileVersion + ) { + return null + } + return packageDir +} + +function findInStore({ + storeDir, + name, + lockfileVersion, + fallbackVersion, +}: { + storeDir: string + name: string + lockfileVersion?: string + fallbackVersion?: string +}): string | null { + // Store entries are named @-, grouped by scope. + const entriesDir = name.startsWith("@") + ? join(storeDir, name.split("/")[0]) + : storeDir + if (!existsSync(entriesDir)) { + return null + } + + const entryPrefix = `${name.split("/").pop()}@` + const candidates = readdirSync(entriesDir) + .filter((entry) => entry.startsWith(entryPrefix)) + .sort() + .map((entry) => join(entriesDir, entry, "node_modules", name)) + .filter((candidate) => existsSync(join(candidate, "package.json"))) + + const findVersion = (version: string) => + candidates.find((candidate) => readPackageVersion(candidate) === version) + + let match: string | undefined + if (lockfileVersion) { + // makePatch diffs against the lockfile version, so never substitute another + match = findVersion(lockfileVersion) + } else if (fallbackVersion) { + match = findVersion(fallbackVersion) + } + if (!match && !lockfileVersion && candidates.length === 1) { + match = candidates[0] + } + + return match ? realpathSync(match) : null } /** - * Walks up parent directories looking for the package in ancestor - * node_modules directories. This handles monorepo hoisting where - * dependencies are installed in the workspace root node_modules - * rather than the package's own node_modules. + * Mirrors Node's module lookup from the parent's real location, which finds + * the sibling symlinks npm creates inside each .store entry. */ -function resolveFromAncestors({ - appPath, - packageName, +function resolveDependency({ + fromDir, + name, + appRealPath, }: { - appPath: string - packageName: string + fromDir: string + name: string + appRealPath: string }): string | null { - let currentDir = resolve(appPath, "..") + let currentDir = fromDir - while (true) { - const candidateFullPath = join(currentDir, "node_modules", packageName) - if (existsSync(candidateFullPath)) { - return relative(appPath, candidateFullPath) + while (!relative(appRealPath, currentDir).startsWith("..")) { + if (basename(currentDir) !== "node_modules") { + const candidate = join(currentDir, "node_modules", name) + if (existsSync(candidate)) { + return realpathSync(candidate) + } } - const parentDir = resolve(currentDir, "..") + const parentDir = dirname(currentDir) if (parentDir === currentDir) { - // Reached filesystem root break } currentDir = parentDir @@ -138,3 +175,28 @@ function resolveFromAncestors({ return null } + +type LockfilePackages = Record + +function readLockfilePackages(appPath: string): LockfilePackages { + for (const lockfileName of ["npm-shrinkwrap.json", "package-lock.json"]) { + try { + const lockfile = JSON.parse( + readFileSync(join(appPath, lockfileName), "utf8"), + ) + return lockfile.packages || {} + } catch (e) { + // noop + } + } + return {} +} + +function readPackageVersion(packageDir: string): string | undefined { + try { + return JSON.parse(readFileSync(join(packageDir, "package.json"), "utf8")) + .version + } catch (e) { + return undefined + } +} From 6d9daa964e267e78e3059426f82417c01a26b7a8 Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Mon, 14 Sep 2026 14:55:23 +0530 Subject: [PATCH 03/10] fix: use the resolved package path throughout makePatch Reading VCS details, the state file and fast-forwarding during a rebase all used the logical node_modules path, which does not exist for packages that only live in .store. --- src/applyPatches.ts | 5 ++++- src/createIssue.ts | 10 +++++++--- src/makePatch.ts | 19 ++++++++++++------- 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/src/applyPatches.ts b/src/applyPatches.ts index 9e2150b3..0c937b99 100644 --- a/src/applyPatches.ts +++ b/src/applyPatches.ts @@ -297,7 +297,10 @@ export function applyPatchesForPackage({ } logPatchApplication(patchDetails) } else if (patches.length > 1) { - logPatchSequenceError({ patchDetails }) + logPatchSequenceError({ + patchDetails, + resolvedPath: resolvedPackagePath, + }) // in case the package has multiple patches, we need to break out of this inner loop // because we don't want to apply more patches on top of the broken state failedPatch = patchDetails diff --git a/src/createIssue.ts b/src/createIssue.ts index 1522e563..280d31b9 100644 --- a/src/createIssue.ts +++ b/src/createIssue.ts @@ -36,9 +36,13 @@ function parseRepoString(repository: string): VCS { return { org, repo, provider: "GitHub" } } -export function getPackageVCSDetails(packageDetails: PackageDetails): VCS { - const repository = require(resolve(join(packageDetails.path, "package.json"))) - .repository as undefined | string | { url: string } +export function getPackageVCSDetails( + packageDetails: PackageDetails, + resolvedPath?: string, +): VCS { + const repository = require(resolve( + join(resolvedPath || packageDetails.path, "package.json"), + )).repository as undefined | string | { url: string } if (!repository) { return null diff --git a/src/makePatch.ts b/src/makePatch.ts index f20e26bd..2004b72f 100644 --- a/src/makePatch.ts +++ b/src/makePatch.ts @@ -82,7 +82,7 @@ export function makePatch({ } const resolvedPackagePath = resolvePackagePath({ appPath, packageDetails }) - const state = getPatchApplicationState(packageDetails) + const state = getPatchApplicationState(packageDetails, resolvedPackagePath) const isRebasing = state?.isRebasing ?? false // If we are rebasing and no patches have been applied, --append is the only valid option because @@ -139,7 +139,7 @@ export function makePatch({ mode.type === "append" || existingPatches.length === 0 ? existingPatches.length + 1 : existingPatches.length - const vcs = getPackageVCSDetails(packageDetails) + const vcs = getPackageVCSDetails(packageDetails, resolvedPackagePath) const canCreateIssue = !isRebasing && shouldRecommendIssue(vcs) && @@ -505,10 +505,14 @@ export function makePatch({ reverse: false, cwd: process.cwd(), bestEffort: false, + resolvedPath: resolvedPackagePath, }) ) { didFailWhileFinishingRebase = true - logPatchSequenceError({ patchDetails: patch }) + logPatchSequenceError({ + patchDetails: patch, + resolvedPath: resolvedPackagePath, + }) nextState.push({ patchFilename: patch.patchFilename, didApply: false, @@ -532,9 +536,10 @@ export function makePatch({ packageDetails, patches: nextState, isRebasing: didFailWhileFinishingRebase, + resolvedPath: resolvedPackagePath, }) } else { - clearPatchApplicationState(packageDetails) + clearPatchApplicationState(packageDetails, resolvedPackagePath) } if (canCreateIssue) { @@ -584,8 +589,10 @@ function createPatchFileName({ export function logPatchSequenceError({ patchDetails, + resolvedPath = patchDetails.path, }: { patchDetails: PatchedPackageDetails + resolvedPath?: string }) { console.log(` ${chalk.red.bold("⛔ ERROR")} @@ -600,9 +607,7 @@ To partially apply the patch (if possible) and output a log of errors to fix, ru ${chalk.bold(`patch-package --partial`)} -After which you should make any required changes inside ${ - patchDetails.path - }, and finally run +After which you should make any required changes inside ${resolvedPath}, and finally run ${chalk.bold(`patch-package ${patchDetails.pathSpecifier}`)} From 35837073ea0f80a3d586df19565a16d45a7814a4 Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Mon, 14 Sep 2026 14:55:33 +0530 Subject: [PATCH 04/10] fix: use the resolved package path when rebasing Rebase looked up the state file and reversed patches at the logical node_modules path, so it reported no state for .store-only packages. --- src/rebase.ts | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/rebase.ts b/src/rebase.ts index d8f0ba9c..4d45e489 100644 --- a/src/rebase.ts +++ b/src/rebase.ts @@ -4,6 +4,7 @@ import { applyPatch } from "./applyPatches" import { hashFile } from "./hash" import { PatchedPackageDetails } from "./PackageDetails" import { getGroupedPatches } from "./patchFs" +import { resolvePackagePath } from "./resolvePackagePath" import { getPatchApplicationState, savePatchApplicationState, @@ -39,7 +40,12 @@ export function rebase({ process.exit(1) } - const state = getPatchApplicationState(packagePatches[0]) + const resolvedPackagePath = resolvePackagePath({ + appPath, + packageDetails: packagePatches[0], + version: packagePatches[0].version, + }) + const state = getPatchApplicationState(packagePatches[0], resolvedPackagePath) if (!state) { console.log( @@ -54,7 +60,7 @@ export function rebase({ console.log( chalk.blueBright("Already rebasing"), "Make changes to the files in", - chalk.bold(packagePatches[0].path), + chalk.bold(resolvedPackagePath), "and then run `patch-package", packagePathSpecifier, "--continue` to", @@ -84,14 +90,16 @@ export function rebase({ patches: packagePatches, appPath, patchDir, + resolvedPath: resolvedPackagePath, }) savePatchApplicationState({ packageDetails: packagePatches[0], isRebasing: true, + resolvedPath: resolvedPackagePath, patches: [], }) console.log(` -Make any changes you need inside ${chalk.bold(packagePatches[0].path)} +Make any changes you need inside ${chalk.bold(resolvedPackagePath)} When you are done, run @@ -161,10 +169,12 @@ to insert a new patch file. patches: packagePatches.slice(targetIdx + 1), appPath, patchDir, + resolvedPath: resolvedPackagePath, }) savePatchApplicationState({ packageDetails: packagePatches[0], isRebasing: true, + resolvedPath: resolvedPackagePath, patches: packagePatches.slice(0, targetIdx + 1).map((p) => ({ patchFilename: p.patchFilename, patchContentHash: hashFile(join(patchesDirectory, p.patchFilename)), @@ -173,7 +183,7 @@ to insert a new patch file. }) console.log(` -Make any changes you need inside ${chalk.bold(packagePatches[0].path)} +Make any changes you need inside ${chalk.bold(resolvedPackagePath)} When you are done, do one of the following: @@ -196,10 +206,12 @@ function unApplyPatches({ patches, appPath, patchDir, + resolvedPath, }: { patches: PatchedPackageDetails[] appPath: string patchDir: string + resolvedPath: string }) { for (const patch of patches.slice().reverse()) { if ( @@ -210,6 +222,7 @@ function unApplyPatches({ patchDir, cwd: process.cwd(), bestEffort: false, + resolvedPath, }) ) { console.log( From 8ae929119c861ff49808fd2f4c2eff8500feb839 Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Mon, 14 Sep 2026 14:55:35 +0530 Subject: [PATCH 05/10] fix: force a hoisted install in the makePatch temp repo The app's .npmrc is copied into the temp repo, so install-strategy=linked turned the package into a symlink and git could not diff it. --- src/makePatch.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/makePatch.ts b/src/makePatch.ts index 2004b72f..703e50ee 100644 --- a/src/makePatch.ts +++ b/src/makePatch.ts @@ -233,11 +233,14 @@ export function makePatch({ chalk.grey("•"), `Installing ${packageDetails.name}@${packageVersion} with npm`, ) + // a copied .npmrc with install-strategy=linked would make the package a symlink git can't diff + const npmEnv = { ...process.env, npm_config_install_strategy: "hoisted" } try { // try first without ignoring scripts in case they are required // this works in 99.99% of cases spawnSafeSync(`npm`, ["i", "--force"], { cwd: tmpRepoNpmRoot, + env: npmEnv, logStdErrOnError: false, stdio: "ignore", }) @@ -246,6 +249,7 @@ export function makePatch({ // an implicit context which we haven't reproduced spawnSafeSync(`npm`, ["i", "--ignore-scripts", "--force"], { cwd: tmpRepoNpmRoot, + env: npmEnv, stdio: "ignore", }) } From 4c961c0ef3623e80ae65cb0fdcc894b0ebfee606 Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Mon, 14 Sep 2026 15:11:03 +0530 Subject: [PATCH 06/10] fix: harden .store resolution for workspaces and ambiguous entries Follow workspace links into the root .store, read versions from the store entry name so patches to package.json stay resolvable, and refuse to pick between same-version entries that npm keys by dependency graph. --- src/resolvePackagePath.test.ts | 156 ++++++++++++++++++++------------- src/resolvePackagePath.ts | 100 ++++++++++++--------- 2 files changed, 157 insertions(+), 99 deletions(-) diff --git a/src/resolvePackagePath.test.ts b/src/resolvePackagePath.test.ts index a17db473..b0603208 100644 --- a/src/resolvePackagePath.test.ts +++ b/src/resolvePackagePath.test.ts @@ -5,13 +5,18 @@ import { getPatchDetailsFromCliString } from "./PackageDetails" import { join } from "./path" import { resolvePackagePath } from "./resolvePackagePath" +const HASH = "hXErYF6ZrdpweGIFH-nM4Q" +const OTHER_HASH = "4cO0ZloNu8RvnBPVrlAb_Q" + describe("resolvePackagePath", () => { + let rootPath: string let appPath: string let cleanup: () => void beforeEach(() => { const tmp = dirSync({ unsafeCleanup: true }) - appPath = realpathSync(tmp.name) + rootPath = realpathSync(tmp.name) + appPath = rootPath cleanup = tmp.removeCallback }) @@ -19,17 +24,19 @@ describe("resolvePackagePath", () => { cleanup() }) - function addStoreEntry(entry: string, name: string, version: string) { - const packageDir = join( - appPath, + function addStoreEntry(name: string, version: string, hash = HASH) { + const storePath = join( "node_modules/.store", - entry, + `${name}@${version}-${hash}`, "node_modules", name, ) - mkdirpSync(packageDir) - writeFileSync(join(packageDir, "package.json"), JSON.stringify({ version })) - return join("node_modules/.store", entry, "node_modules", name) + mkdirpSync(join(appPath, storePath)) + writeFileSync( + join(appPath, storePath, "package.json"), + JSON.stringify({ name, version }), + ) + return storePath } function link(from: string, to: string) { @@ -55,7 +62,7 @@ describe("resolvePackagePath", () => { it("returns the original path when the package exists", () => { mkdirpSync(join(appPath, "node_modules/some-package")) - addStoreEntry("some-package@1.0.0-hash", "some-package", "1.0.0") + addStoreEntry("some-package", "1.0.0") expect(resolve("some-package")).toBe("node_modules/some-package") }) @@ -65,36 +72,38 @@ describe("resolvePackagePath", () => { }) it("returns the original path when the package is not in .store", () => { - addStoreEntry("other@1.0.0-hash", "other", "1.0.0") + addStoreEntry("other", "1.0.0") expect(resolve("some-package")).toBe("node_modules/some-package") }) it("resolves a transitive package from .store", () => { - const storePath = addStoreEntry("ms@2.1.2-hash", "ms", "2.1.2") + const storePath = addStoreEntry("ms", "2.1.2") expect(resolve("ms")).toBe(storePath) }) it("does not match packages sharing a name prefix", () => { - addStoreEntry("ms-extra@1.0.0-hash", "ms-extra", "1.0.0") + addStoreEntry("ms-extra", "1.0.0") expect(resolve("ms")).toBe("node_modules/ms") }) it("resolves a scoped transitive package from .store", () => { - const storePath = addStoreEntry( - "@babel/highlight@7.25.9-hash", - "@babel/highlight", - "7.25.9", - ) + const storePath = addStoreEntry("@babel/highlight", "7.25.9") expect(resolve("@babel/highlight")).toBe(storePath) }) + it("resolves prerelease versions from the store entry name", () => { + const storePath = addStoreEntry("ms", "3.0.0-canary.1") + + expect(resolve("ms", "3.0.0-canary.1")).toBe(storePath) + }) + it("picks the lockfile version when .store has several versions", () => { - addStoreEntry("ms@2.0.0-hash", "ms", "2.0.0") - const storePath = addStoreEntry("ms@2.1.2-hash", "ms", "2.1.2") + addStoreEntry("ms", "2.0.0", OTHER_HASH) + const storePath = addStoreEntry("ms", "2.1.2") writeLockfile({ "node_modules/ms": { version: "2.1.2" } }) expect(resolve("ms")).toBe(storePath) @@ -102,31 +111,54 @@ describe("resolvePackagePath", () => { }) it("falls back to the given version without a lockfile entry", () => { - addStoreEntry("ms@2.0.0-hash", "ms", "2.0.0") - const storePath = addStoreEntry("ms@2.1.2-hash", "ms", "2.1.2") + addStoreEntry("ms", "2.0.0", OTHER_HASH) + const storePath = addStoreEntry("ms", "2.1.2") expect(resolve("ms", "2.1.2")).toBe(storePath) }) it("does not guess between several versions", () => { - addStoreEntry("ms@2.0.0-hash", "ms", "2.0.0") - addStoreEntry("ms@2.1.2-hash", "ms", "2.1.2") + addStoreEntry("ms", "2.0.0", OTHER_HASH) + addStoreEntry("ms", "2.1.2") expect(resolve("ms")).toBe("node_modules/ms") expect(resolve("ms", "3.0.0")).toBe("node_modules/ms") }) - it("resolves a nested package through its parent's store entry", () => { - const parentPath = addStoreEntry( - "finalhandler@1.2.0-hash", - "finalhandler", - "1.2.0", + it("does not substitute another version for the lockfile version", () => { + addStoreEntry("ms", "2.0.0") + writeLockfile({ "node_modules/ms": { version: "2.1.2" } }) + + expect(resolve("ms", "2.0.0")).toBe("node_modules/ms") + }) + + it("does not guess between same-version entries", () => { + addStoreEntry("ms", "2.1.2") + addStoreEntry("ms", "2.1.2", OTHER_HASH) + writeLockfile({ "node_modules/ms": { version: "2.1.2" } }) + + expect(resolve("ms")).toBe("node_modules/ms") + }) + + it("keeps resolving after a patch changes the package.json version", () => { + const storePath = addStoreEntry("ms", "2.1.2") + addStoreEntry("ms", "2.0.0", OTHER_HASH) + writeFileSync( + join(appPath, storePath, "package.json"), + JSON.stringify({ version: "2.1.2-patched" }), ) - addStoreEntry("ms@2.1.2-hash", "ms", "2.1.2") - const nestedPath = addStoreEntry("ms@2.0.0-hash", "ms", "2.0.0") + writeLockfile({ "node_modules/ms": { version: "2.1.2" } }) + + expect(resolve("ms")).toBe(storePath) + }) + + it("resolves a nested package through its parent's store entry", () => { + const parentPath = addStoreEntry("finalhandler", "1.2.0") + addStoreEntry("ms", "2.1.2") + const nestedPath = addStoreEntry("ms", "2.0.0", OTHER_HASH) link("node_modules/finalhandler", parentPath) link( - "node_modules/.store/finalhandler@1.2.0-hash/node_modules/ms", + `node_modules/.store/finalhandler@1.2.0-${HASH}/node_modules/ms`, nestedPath, ) @@ -134,13 +166,9 @@ describe("resolvePackagePath", () => { }) it("resolves a nested package whose lockfile parent is not its dependent", () => { - const parentPath = addStoreEntry( - "finalhandler@1.2.0-hash", - "finalhandler", - "1.2.0", - ) - const directPath = addStoreEntry("ms@2.1.2-hash", "ms", "2.1.2") - const nestedPath = addStoreEntry("ms@2.0.0-hash", "ms", "2.0.0") + const parentPath = addStoreEntry("finalhandler", "1.2.0") + const directPath = addStoreEntry("ms", "2.1.2") + const nestedPath = addStoreEntry("ms", "2.0.0", OTHER_HASH) link("node_modules/finalhandler", parentPath) link("node_modules/ms", directPath) writeLockfile({ @@ -151,35 +179,33 @@ describe("resolvePackagePath", () => { expect(resolve("finalhandler/ms")).toBe(nestedPath) }) - it("does not substitute another version for the lockfile version", () => { - addStoreEntry("ms@2.0.0-hash", "ms", "2.0.0") - writeLockfile({ "node_modules/ms": { version: "2.1.2" } }) - - expect(resolve("ms", "2.0.0")).toBe("node_modules/ms") - }) - it("resolves a scoped nested package through a scoped parent", () => { - const parentPath = addStoreEntry( - "@babel/code-frame@7.24.7-hash", - "@babel/code-frame", - "7.24.7", - ) - const nestedPath = addStoreEntry( - "@babel/highlight@7.25.9-hash", - "@babel/highlight", - "7.25.9", - ) + const parentPath = addStoreEntry("@babel/code-frame", "7.24.7") + const nestedPath = addStoreEntry("@babel/highlight", "7.25.9") link("node_modules/@babel/code-frame", parentPath) link( - "node_modules/.store/@babel/code-frame@7.24.7-hash/node_modules/@babel/highlight", + `node_modules/.store/@babel/code-frame@7.24.7-${HASH}/node_modules/@babel/highlight`, nestedPath, ) expect(resolve("@babel/code-frame/@babel/highlight")).toBe(nestedPath) }) + it("resolves a nested package in a workspace linked to the root .store", () => { + const parentPath = addStoreEntry("parent", "1.0.0") + const childPath = addStoreEntry("child", "1.0.0") + link( + `node_modules/.store/parent@1.0.0-${HASH}/node_modules/child`, + childPath, + ) + link("packages/a/node_modules/parent", parentPath) + appPath = join(rootPath, "packages/a") + + expect(resolve("parent/child")).toBe(join("../..", childPath)) + }) + it("returns the original path when a nested package is missing", () => { - const parentPath = addStoreEntry("parent@1.0.0-hash", "parent", "1.0.0") + const parentPath = addStoreEntry("parent", "1.0.0") link("node_modules/parent", parentPath) expect(resolve("parent/child")).toBe( @@ -187,11 +213,21 @@ describe("resolvePackagePath", () => { ) }) + it("leaves regular installs untouched", () => { + mkdirpSync(join(appPath, "node_modules/parent")) + mkdirpSync(join(appPath, "node_modules/child")) + addStoreEntry("other", "1.0.0") + + expect(resolve("parent/child")).toBe( + "node_modules/parent/node_modules/child", + ) + }) + it("does not resolve nested packages outside appPath", () => { - appPath = join(appPath, "app") - const parentPath = addStoreEntry("parent@1.0.0-hash", "parent", "1.0.0") + appPath = join(rootPath, "app") + const parentPath = addStoreEntry("parent", "1.0.0") link("node_modules/parent", parentPath) - mkdirpSync(join(appPath, "../node_modules/child")) + mkdirpSync(join(rootPath, "node_modules/child")) expect(resolve("parent/child")).toBe( "node_modules/parent/node_modules/child", diff --git a/src/resolvePackagePath.ts b/src/resolvePackagePath.ts index d32564f6..be86d7a6 100644 --- a/src/resolvePackagePath.ts +++ b/src/resolvePackagePath.ts @@ -22,12 +22,8 @@ export function resolvePackagePath({ return path } - const storeDir = join(appPath, "node_modules", ".store") - if (!existsSync(storeDir)) { - return path - } - const appRealPath = realpathSync(appPath) + const storeDir = join(appPath, "node_modules", ".store") const lockfilePackages = readLockfilePackages(appPath) const lockfileVersion = lockfilePackages[path]?.version @@ -90,11 +86,16 @@ function resolveThroughParents({ packageDir = resolveDependency({ fromDir: packageDir, name, appRealPath }) } - if ( - packageDir && - lockfileVersion && - readPackageVersion(packageDir) !== lockfileVersion - ) { + if (!packageDir) { + return null + } + + // anything outside .store is a regular install, which is left untouched + const storeVersion = getStoreEntryVersion( + packageDir, + packageNames.slice(-1)[0], + ) + if (!storeVersion || (lockfileVersion && storeVersion !== lockfileVersion)) { return null } return packageDir @@ -111,7 +112,6 @@ function findInStore({ lockfileVersion?: string fallbackVersion?: string }): string | null { - // Store entries are named @-, grouped by scope. const entriesDir = name.startsWith("@") ? join(storeDir, name.split("/")[0]) : storeDir @@ -119,28 +119,55 @@ function findInStore({ return null } - const entryPrefix = `${name.split("/").pop()}@` const candidates = readdirSync(entriesDir) - .filter((entry) => entry.startsWith(entryPrefix)) - .sort() .map((entry) => join(entriesDir, entry, "node_modules", name)) + .filter((candidate) => getStoreEntryVersion(candidate, name)) .filter((candidate) => existsSync(join(candidate, "package.json"))) - const findVersion = (version: string) => - candidates.find((candidate) => readPackageVersion(candidate) === version) + // makePatch diffs against the lockfile version, so never substitute another + const wantedVersion = lockfileVersion || fallbackVersion + const matches = wantedVersion + ? candidates.filter( + (candidate) => getStoreEntryVersion(candidate, name) === wantedVersion, + ) + : [] - let match: string | undefined - if (lockfileVersion) { - // makePatch diffs against the lockfile version, so never substitute another - match = findVersion(lockfileVersion) - } else if (fallbackVersion) { - match = findVersion(fallbackVersion) + if (!matches.length && !lockfileVersion && candidates.length === 1) { + return realpathSync(candidates[0]) } - if (!match && !lockfileVersion && candidates.length === 1) { - match = candidates[0] + // same-version entries differ by dependency graph, and we can't tell which is used + return matches.length === 1 ? realpathSync(matches[0]) : null +} + +/** + * Store entries are node_modules/.store/@-/node_modules/. + * Reading the version from the entry name keeps patches to package.json resolvable. + */ +function getStoreEntryVersion( + packageDir: string, + name: string, +): string | undefined { + const suffix = `/node_modules/${name}` + const normalizedDir = join(packageDir) + if (!normalizedDir.endsWith(suffix)) { + return undefined } - return match ? realpathSync(match) : null + const entryDir = normalizedDir.slice(0, -suffix.length) + const storeDir = name.startsWith("@") + ? dirname(dirname(entryDir)) + : dirname(entryDir) + const match = basename(entryDir).match(/^(?:.+?)@(.+)-[\w-]{22}$/) + + if ( + basename(storeDir) !== ".store" || + basename(dirname(storeDir)) !== "node_modules" || + !match || + !basename(entryDir).startsWith(`${name.split("/").pop()}@`) + ) { + return undefined + } + return match[1] } /** @@ -158,7 +185,7 @@ function resolveDependency({ }): string | null { let currentDir = fromDir - while (!relative(appRealPath, currentDir).startsWith("..")) { + while (true) { if (basename(currentDir) !== "node_modules") { const candidate = join(currentDir, "node_modules", name) if (existsSync(candidate)) { @@ -166,14 +193,18 @@ function resolveDependency({ } } + // workspace links point into the root .store, outside appPath const parentDir = dirname(currentDir) - if (parentDir === currentDir) { - break + if ( + parentDir === currentDir || + basename(parentDir) === ".store" || + (!join(parentDir).includes("/node_modules/.store/") && + relative(appRealPath, parentDir).startsWith("..")) + ) { + return null } currentDir = parentDir } - - return null } type LockfilePackages = Record @@ -191,12 +222,3 @@ function readLockfilePackages(appPath: string): LockfilePackages { } return {} } - -function readPackageVersion(packageDir: string): string | undefined { - try { - return JSON.parse(readFileSync(join(packageDir, "package.json"), "utf8")) - .version - } catch (e) { - return undefined - } -} From 906d0b735fdba6ebaa8db24eed7e4a263274d5c1 Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Mon, 14 Sep 2026 15:19:39 +0530 Subject: [PATCH 07/10] fix: use the resolved package path when opening an issue --create-issue looked up VCS details again at the logical path, which throws for packages that only live in .store. --- src/createIssue.ts | 4 +++- src/makePatch.ts | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/createIssue.ts b/src/createIssue.ts index 280d31b9..d8163169 100644 --- a/src/createIssue.ts +++ b/src/createIssue.ts @@ -125,13 +125,15 @@ export function openIssueCreationLink({ patchFileContents, packageVersion, patchPath, + resolvedPath, }: { packageDetails: PackageDetails patchFileContents: string packageVersion: string patchPath: string + resolvedPath?: string }) { - const vcs = getPackageVCSDetails(packageDetails) + const vcs = getPackageVCSDetails(packageDetails, resolvedPath) if (!vcs) { console.log( diff --git a/src/makePatch.ts b/src/makePatch.ts index 703e50ee..6b5aa493 100644 --- a/src/makePatch.ts +++ b/src/makePatch.ts @@ -553,6 +553,7 @@ export function makePatch({ patchFileContents: diffResult.stdout.toString(), packageVersion, patchPath, + resolvedPath: resolvedPackagePath, }) } else { maybePrintIssueCreationPrompt(vcs, packageDetails, packageManager) From 1b340306207cc6b2d2b6fc37a51dcb824551dbad Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Mon, 14 Sep 2026 15:28:00 +0530 Subject: [PATCH 08/10] fix: resolve npm aliased packages in .store Aliased dependencies are stored under their real package name, which the lockfile records in the entry's name field. --- src/resolvePackagePath.test.ts | 30 ++++++++++++++++++ src/resolvePackagePath.ts | 56 ++++++++++++++-------------------- 2 files changed, 53 insertions(+), 33 deletions(-) diff --git a/src/resolvePackagePath.test.ts b/src/resolvePackagePath.test.ts index b0603208..2cac3445 100644 --- a/src/resolvePackagePath.test.ts +++ b/src/resolvePackagePath.test.ts @@ -204,6 +204,36 @@ describe("resolvePackagePath", () => { expect(resolve("parent/child")).toBe(join("../..", childPath)) }) + it("resolves an aliased transitive package by its lockfile name", () => { + addStoreEntry("string-width", "5.1.2", OTHER_HASH) + const storePath = addStoreEntry("string-width", "4.2.3") + writeFileSync( + join(appPath, "package-lock.json"), + JSON.stringify({ + packages: { + "node_modules/string-width-cjs": { + name: "string-width", + version: "4.2.3", + }, + }, + }), + ) + + expect(resolve("string-width-cjs")).toBe(storePath) + }) + + it("resolves an aliased nested package through its parent's store entry", () => { + const parentPath = addStoreEntry("@isaacs/cliui", "8.0.2") + const aliasedPath = addStoreEntry("string-width", "4.2.3") + link("node_modules/@isaacs/cliui", parentPath) + link( + `node_modules/.store/@isaacs/cliui@8.0.2-${HASH}/node_modules/string-width-cjs`, + aliasedPath, + ) + + expect(resolve("@isaacs/cliui/string-width-cjs")).toBe(aliasedPath) + }) + it("returns the original path when a nested package is missing", () => { const parentPath = addStoreEntry("parent", "1.0.0") link("node_modules/parent", parentPath) diff --git a/src/resolvePackagePath.ts b/src/resolvePackagePath.ts index be86d7a6..5eaa4d87 100644 --- a/src/resolvePackagePath.ts +++ b/src/resolvePackagePath.ts @@ -26,6 +26,8 @@ export function resolvePackagePath({ const storeDir = join(appPath, "node_modules", ".store") const lockfilePackages = readLockfilePackages(appPath) const lockfileVersion = lockfilePackages[path]?.version + // npm aliases record the real package name, which is what .store uses + const storeName = lockfilePackages[path]?.name || name const packageDir = resolveThroughParents({ @@ -38,7 +40,7 @@ export function resolvePackagePath({ }) || findInStore({ storeDir, - name, + name: storeName, lockfileVersion, fallbackVersion: version, }) @@ -75,7 +77,7 @@ function resolveThroughParents({ ? realpathSync(join(appPath, rootPath)) : findInStore({ storeDir, - name: rootName, + name: lockfilePackages[rootPath]?.name || rootName, lockfileVersion: lockfilePackages[rootPath]?.version, }) @@ -91,11 +93,11 @@ function resolveThroughParents({ } // anything outside .store is a regular install, which is left untouched - const storeVersion = getStoreEntryVersion( - packageDir, - packageNames.slice(-1)[0], - ) - if (!storeVersion || (lockfileVersion && storeVersion !== lockfileVersion)) { + const storeEntry = parseStoreEntry(packageDir) + if ( + !storeEntry || + (lockfileVersion && storeEntry.version !== lockfileVersion) + ) { return null } return packageDir @@ -121,14 +123,14 @@ function findInStore({ const candidates = readdirSync(entriesDir) .map((entry) => join(entriesDir, entry, "node_modules", name)) - .filter((candidate) => getStoreEntryVersion(candidate, name)) + .filter((candidate) => parseStoreEntry(candidate)?.name === name) .filter((candidate) => existsSync(join(candidate, "package.json"))) // makePatch diffs against the lockfile version, so never substitute another const wantedVersion = lockfileVersion || fallbackVersion const matches = wantedVersion ? candidates.filter( - (candidate) => getStoreEntryVersion(candidate, name) === wantedVersion, + (candidate) => parseStoreEntry(candidate)?.version === wantedVersion, ) : [] @@ -141,33 +143,18 @@ function findInStore({ /** * Store entries are node_modules/.store/@-/node_modules/. - * Reading the version from the entry name keeps patches to package.json resolvable. + * Reading the version from the path keeps patches to package.json resolvable. */ -function getStoreEntryVersion( +function parseStoreEntry( packageDir: string, - name: string, -): string | undefined { - const suffix = `/node_modules/${name}` - const normalizedDir = join(packageDir) - if (!normalizedDir.endsWith(suffix)) { - return undefined - } - - const entryDir = normalizedDir.slice(0, -suffix.length) - const storeDir = name.startsWith("@") - ? dirname(dirname(entryDir)) - : dirname(entryDir) - const match = basename(entryDir).match(/^(?:.+?)@(.+)-[\w-]{22}$/) - - if ( - basename(storeDir) !== ".store" || - basename(dirname(storeDir)) !== "node_modules" || - !match || - !basename(entryDir).startsWith(`${name.split("/").pop()}@`) - ) { +): { name: string; version: string } | undefined { + const match = join(packageDir).match( + /\/node_modules\/\.store\/((?:@[^/]+\/)?[^/@]+)@([^/]+)-[\w-]{22}\/node_modules\/(.+)$/, + ) + if (!match || match[1] !== match[3]) { return undefined } - return match[1] + return { name: match[1], version: match[2] } } /** @@ -207,7 +194,10 @@ function resolveDependency({ } } -type LockfilePackages = Record +type LockfilePackages = Record< + string, + { name?: string; version?: string } | undefined +> function readLockfilePackages(appPath: string): LockfilePackages { for (const lockfileName of ["npm-shrinkwrap.json", "package-lock.json"]) { From 3db9cadc52ea473a9b824cda7f40bd22e47b1303 Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Mon, 14 Sep 2026 15:42:35 +0530 Subject: [PATCH 09/10] fix: only resolve .store paths the lockfile knows about makePatch reads the lockfile entry for the requested path, so resolving a nested spec that npm hoists elsewhere crashed later. Check that the package exists before any lookup that assumes it does. --- src/makePatch.ts | 21 +++++++++++---------- src/resolvePackagePath.test.ts | 19 +++++++++++++++++++ src/resolvePackagePath.ts | 4 ++++ 3 files changed, 34 insertions(+), 10 deletions(-) diff --git a/src/makePatch.ts b/src/makePatch.ts index 6b5aa493..c41ff4fa 100644 --- a/src/makePatch.ts +++ b/src/makePatch.ts @@ -82,6 +82,17 @@ export function makePatch({ } const resolvedPackagePath = resolvePackagePath({ appPath, packageDetails }) + const packagePath = join(appPath, resolvedPackagePath) + const packageJsonPath = join(packagePath, "package.json") + + if (!existsSync(packageJsonPath)) { + printNoPackageFoundError( + packagePathSpecifier, + join(appPath, packageDetails.path, "package.json"), + ) + process.exit(1) + } + const state = getPatchApplicationState(packageDetails, resolvedPackagePath) const isRebasing = state?.isRebasing ?? false @@ -147,16 +158,6 @@ export function makePatch({ mode.type !== "append" const appPackageJson = require(join(appPath, "package.json")) - const packagePath = join(appPath, resolvedPackagePath) - const packageJsonPath = join(packagePath, "package.json") - - if (!existsSync(packageJsonPath)) { - printNoPackageFoundError( - packagePathSpecifier, - join(appPath, packageDetails.path, "package.json"), - ) - process.exit(1) - } const tmpRepo = dirSync({ unsafeCleanup: true }) const tmpRepoPackagePath = join(tmpRepo.name, packageDetails.path) diff --git a/src/resolvePackagePath.test.ts b/src/resolvePackagePath.test.ts index 2cac3445..6bff7d64 100644 --- a/src/resolvePackagePath.test.ts +++ b/src/resolvePackagePath.test.ts @@ -234,6 +234,25 @@ describe("resolvePackagePath", () => { expect(resolve("@isaacs/cliui/string-width-cjs")).toBe(aliasedPath) }) + it("does not resolve a nested path the lockfile hoists elsewhere", () => { + const parentPath = addStoreEntry("esbuild", "0.20.0") + const childPath = addStoreEntry("@esbuild/darwin-arm64", "0.20.0") + link("node_modules/esbuild", parentPath) + link( + `node_modules/.store/esbuild@0.20.0-${HASH}/node_modules/@esbuild/darwin-arm64`, + childPath, + ) + writeLockfile({ + "node_modules/esbuild": { version: "0.20.0" }, + "node_modules/@esbuild/darwin-arm64": { version: "0.20.0" }, + }) + + expect(resolve("esbuild/@esbuild/darwin-arm64")).toBe( + "node_modules/esbuild/node_modules/@esbuild/darwin-arm64", + ) + expect(resolve("@esbuild/darwin-arm64")).toBe(childPath) + }) + it("returns the original path when a nested package is missing", () => { const parentPath = addStoreEntry("parent", "1.0.0") link("node_modules/parent", parentPath) diff --git a/src/resolvePackagePath.ts b/src/resolvePackagePath.ts index 5eaa4d87..02590011 100644 --- a/src/resolvePackagePath.ts +++ b/src/resolvePackagePath.ts @@ -25,6 +25,10 @@ export function resolvePackagePath({ const appRealPath = realpathSync(appPath) const storeDir = join(appPath, "node_modules", ".store") const lockfilePackages = readLockfilePackages(appPath) + // makePatch reads the lockfile entry for this path, so it must exist + if (Object.keys(lockfilePackages).length && !lockfilePackages[path]) { + return path + } const lockfileVersion = lockfilePackages[path]?.version // npm aliases record the real package name, which is what .store uses const storeName = lockfilePackages[path]?.name || name From a953d427f829b0f29084037e4aadfa50c8ab2158 Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Mon, 14 Sep 2026 16:01:40 +0530 Subject: [PATCH 10/10] fix: restore whitespace in the dev-only patch error message Stripping it changed CLI output and broke the dev-only-patches snapshot. --- src/applyPatches.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/applyPatches.ts b/src/applyPatches.ts index 0c937b99..7c68c32f 100644 --- a/src/applyPatches.ts +++ b/src/applyPatches.ts @@ -57,7 +57,7 @@ function getInstalledPackageVersion({ err += ` If this package is a dev dependency, rename the patch file to - + ${chalk.bold(patchFilename.replace(".patch", ".dev.patch"))} ` }