Repository navigation
Description
Verify latest release
- I verified that the issue exists in the latest pnpm release
pnpm version
12.14.0; also reproduced on 11.28.6. Current main inspected at d37c6d9 (after #17065).
Which area(s) of pnpm are affected? (leave empty if unsure)
No response
Link to the code that reproduces this issue or a replay of the bug
No response
Reproduction steps
Save as repro.cjs and run node repro.cjs with official pnpm 12.14.0 on PATH (or set PNPM_BIN to it). Everything is local/offline and lifecycle scripts are disabled.
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const pnpm = process.env.PNPM_BIN || 'pnpm';
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pnpm-transitive-frozen-'));
function json(dir, value) {
fs.mkdirSync(path.join(root, dir), { recursive: true });
fs.writeFileSync(path.join(root, dir, 'package.json'), JSON.stringify(value));
}
function pkg(dir, name, dependencies, code, version = '1.0.0') {
json(dir, { name, version, main: 'index.cjs', dependencies });
fs.writeFileSync(path.join(root, dir, 'index.cjs'), code);
}
function install(args) {
const r = spawnSync(pnpm, [...args, '--offline', '--ignore-scripts',
'--store-dir', path.join(root, 'store')], {
cwd: root, encoding: 'utf8', env: { ...process.env, CI: 'true' },
});
if (r.error) throw r.error;
console.log('exit=' + r.status, r.stdout, r.stderr);
return r.status;
}
json('', { name: 'root', private: true });
fs.writeFileSync(path.join(root, 'pnpm-workspace.yaml'),
'packages: [app, a]\nsharedWorkspaceLockfile: false\ninjectWorkspacePackages: true\ndedupeInjectedDeps: false\n');
json('app', { name: 'app', private: true, dependencies: { a: 'workspace:*' } });
pkg('a', 'a', { b: 'file:../b' }, "module.exports = require('b');\n");
pkg('b', 'b', { leaf: 'file:../leaf1' }, "module.exports = require('leaf');\n");
pkg('leaf1', 'leaf', {}, "module.exports = '1.0.0';\n");
pkg('leaf2', 'leaf', {}, "module.exports = '2.0.0';\n", '2.0.0');
console.log(spawnSync(pnpm, ['--version'], { encoding: 'utf8' }).stdout.trim());
if (install(['install', '--no-frozen-lockfile']) !== 0) throw Error('initial install failed');
const lock = path.join(root, 'app/pnpm-lock.yaml');
const before = fs.readFileSync(lock);
json('b', { name: 'b', version: '1.0.0', main: 'index.cjs',
dependencies: { leaf: 'file:../leaf2' } });
const frozenExit = install(['--filter=app', 'install', '--frozen-lockfile']);
console.log('frozen exit:', frozenExit);
console.log('consumer lockfile unchanged:', before.equals(fs.readFileSync(lock)));
console.log('actually loaded:', require(path.join(root, 'app/node_modules/a')));
console.log('reproduction:', root);Describe the Bug
The graph is app -> injected a -> file: b -> leaf. Only b/package.json changes, from leaf: file:../leaf1 to file:../leaf2. b is deliberately not a workspace importer, but it is an explicitly declared dependency included in the consumer lockfile.
On official 12.14.0 and 11.28.6:
frozen exit: 0
consumer lockfile unchanged: true
actually loaded: 1.0.0
The current source requires leaf 2.0.0, while the stale consumer snapshot still installs 1.0.0.
Controls executed on 12.14.0:
- Removing root/app/a node_modules before the frozen install gives the same result: exit 0, unchanged lockfile, leaf 1.0.0.
- Adding a dependency directly to a makes the same filtered frozen install fail with ERR_PNPM_OUTDATED_LOCKFILE: local dependency "a" at "../a" is outdated. The immediate-directory check runs, but the next directory dependency is not traversed.
This is deeper than the immediate-child repro in #17008. Current v11 childIsUpToDate delegates local specs to a path/name/version comparison without recursively checking that child's dependency snapshot:
pnpm/pnpm11/lockfile/verification/src/localDirectoryDepIsUpToDate.ts
Lines 190 to 211 in d37c6d9
| async function childIsUpToDate ( | |
| ctx: LocalDirectoryDepContext, | |
| localDep: LocalDirectoryDep, | |
| pkgSnapshot: PackageSnapshot, | |
| child: ManifestWantedDependency | |
| ): Promise<boolean> { | |
| const recorded = child.optional ? pkgSnapshot.optionalDependencies : pkgSnapshot.dependencies | |
| const lockfileDep = recorded?.[child.alias] | |
| if (!lockfileDep) { | |
| // A child recorded as required has changed, not gone unresolved. | |
| return child.optional && ctx.resolution?.frozenLockfile === true && | |
| !pkgSnapshot.dependencies?.[child.alias] | |
| } | |
| const currentSpec = resolveCatalogSpec(ctx, child) | |
| if (currentSpec == null) return false | |
| if (isLocalSpec(currentSpec)) { | |
| return localSpecIsUpToDate(localDep, { depName: child.alias, currentSpec, lockfileDep }) | |
| } | |
| const locked = lockedPackage(ctx, child.alias, lockfileDep) | |
| if (locked == null) return false | |
| return lockedVersionSatisfies({ alias: child.alias, bareSpecifier: currentSpec }, locked) ?? | |
| (ctx.resolution?.frozenLockfile === true && locked.name === getTargetPkgName(currentSpec, child.alias)) |
pnpm/pnpm11/lockfile/verification/src/localDirectoryDepIsUpToDate.ts
Lines 244 to 330 in d37c6d9
| async function localSpecIsUpToDate ( | |
| localDep: LocalDirectoryDep, | |
| { depName, currentSpec, lockfileDep }: DependencyOfLocalDirectoryDep | |
| ): Promise<boolean> { | |
| if (currentSpec.startsWith('link:')) { | |
| return ( | |
| lockfileDep.startsWith('link:') && | |
| resolveSpecPath(localDep.localDepDir, currentSpec.slice(5)) === resolveSpecPath(localDep.lockfileDir, lockfileDep.slice(5)) | |
| ) | |
| } | |
| const cleanLockfileDep = removeSuffix(lockfileDep) | |
| const lockfilePath = getLocalPath(cleanLockfileDep) | |
| if (currentSpec.startsWith('file:')) { | |
| return localPathMatches(localDep, currentSpec.slice(5), lockfilePath) | |
| } | |
| return workspaceDependencyIsUpToDate(localDep, { depName, currentSpec, cleanLockfileDep, lockfilePath }) | |
| } | |
| function localPathMatches (localDep: LocalDirectoryDep, specPath: string, lockfilePath: string | null): boolean { | |
| return lockfilePath != null && | |
| resolveSpecPath(localDep.localDepDir, specPath) === resolveSpecPath(localDep.lockfileDir, lockfilePath) | |
| } | |
| function hasExpectedName (cleanLockfileDep: string, { depName, currentSpec }: { depName: string, currentSpec: string }): boolean { | |
| const expectedName = getTargetPkgName(currentSpec, depName) | |
| const actualName = getDepActualName(cleanLockfileDep, depName) | |
| return actualName === expectedName | |
| } | |
| async function workspaceDependencyIsUpToDate ( | |
| localDep: LocalDirectoryDep, | |
| { depName, currentSpec, cleanLockfileDep, lockfilePath }: { | |
| depName: string | |
| currentSpec: string | |
| cleanLockfileDep: string | |
| lockfilePath: string | null | |
| } | |
| ): Promise<boolean> { | |
| const target = currentSpec.slice(10) | |
| if (isWorkspacePath(target)) { | |
| return localPathMatches(localDep, target, lockfilePath) | |
| } | |
| const range = getVersionRange(currentSpec) | |
| if (lockfilePath != null) { | |
| return linkedWorkspaceTargetSatisfies(localDep, { depName, currentSpec, range, lockfilePath }) | |
| } | |
| if (!hasExpectedName(cleanLockfileDep, { depName, currentSpec })) { | |
| return false | |
| } | |
| const lockfileVersion = getDepVersion(cleanLockfileDep) | |
| return !semver.valid(lockfileVersion) || semver.satisfies(lockfileVersion, range, { loose: true }) | |
| } | |
| async function linkedWorkspaceTargetSatisfies ( | |
| localDep: LocalDirectoryDep, | |
| { depName, currentSpec, range, lockfilePath }: { | |
| depName: string | |
| currentSpec: string | |
| range: string | |
| lockfilePath: string | |
| } | |
| ): Promise<boolean> { | |
| if (path.isAbsolute(lockfilePath)) { | |
| return false | |
| } | |
| const targetDir = path.resolve(localDep.lockfileDir, lockfilePath) | |
| if (!isSubdirectory(localDep.workspaceRoot, targetDir)) { | |
| return false | |
| } | |
| if (!await realPathStaysInsideWorkspace(localDep.workspaceRoot, targetDir)) { | |
| return false | |
| } | |
| const targetPkg = localDep.manifestsByDir[targetDir] ?? await safeReadPackageJsonFromDir(targetDir) | |
| const expectedName = getTargetPkgName(currentSpec, depName) | |
| if (!targetPkg || targetPkg.name !== expectedName) { | |
| return false | |
| } | |
| return range === '*' || range === '^' || range === '~' || range === '' || | |
| semver.satisfies(targetPkg.version, range, { loose: true }) | |
| } | |
| async function realPathStaysInsideWorkspace (workspaceRoot: string, targetDir: string): Promise<boolean> { | |
| let realTargetDir: string | |
| let realWorkspaceRoot: string | |
| let realManifestPath: string | |
| try { | |
| [realTargetDir, realWorkspaceRoot, realManifestPath] = await Promise.all([ |
The current Rust directory check also starts from importer dependencies and compares local child specs without recursively validating their snapshots. I inspected current main; I did not build/run the entire main CLI. The runtime results above are from the published, unmodified releases.
Related: #17008, #17065, pnpm/tasks#145.
Environment: macOS 26.7.1 arm64; Node.js 26.2.0. Official native @pnpm/exe.darwin-arm64 12.14.0 and official npm pnpm 11.28.6, without downstream patches. Reproduction prepared and executed with Codex.
Expected Behavior
Reject the stale consumer lockfile with ERR_PNPM_OUTDATED_LOCKFILE when the manifest of a reachable injected/local-directory package changes incompatibly, even when that package is not an independently checked importer. A recursive walk needs cycle protection and must preserve the normal treatment of links and excluded dependency groups.
Which Node.js version are you using?
26.2.0
Which operating systems have you used?
- macOS
- Windows
- Linux
If your OS is a Linux based, which one it is? (Include the version if relevant)
No response
Contributing a fix
- I am working on a fix
Activity
fix(lockfile): check transitive local directory dependencies on a fro…
test(lockfile): cover transitive local directory dependency freshness
fix(lockfile): check transitive local directory dependencies on a fro…
test(lockfile): cover transitive local directory dependency freshness
test(lockfile): cover a transitive file: directory chain in the Rust …
fix(lockfile): check transitive local directory dependencies on a fro…