Visitar URL original
--frozen-lockfile accepts stale transitive injected/local-directory dependencies without an importer · Issue #17128 · pnpm/pnpm · GitHub
Skip to content

--frozen-lockfile accepts stale transitive injected/local-directory dependencies without an importer #17128

Closed

Description

@baymer

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:

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))

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions