fix: detect changes inside file: dependencies on repeat install (pacquet + pnpm) (#12317)

## Summary

- `pnpm install` reports "Already up to date" after edits inside a `file:` dependency's directory or after repacking a `file:` tarball. This is a v11 regression from the `optimisticRepeatInstall` default flip in pnpm/pnpm#11158. Fixes pnpm/pnpm#11795.
- `checkDepsStatus` gains a `treatLocalFileDepsAsOutdated` option: when set, any project manifest declaring a local file dependency makes the check report not up to date. `installDeps` sets it on the optimistic fast path, so projects with local file dependencies always run a real install, which refetches those dependencies (the v10 behavior).
- The predicate covers `file:` specs, path-prefixed specs (`./`, `../`, `~/`, absolute POSIX paths, and Windows drive paths including drive-relative ones like `C:dir`, matching the local resolver's `isFilespec`), and bare tarball file names (`vendor/pkg.tgz`). It is deliberately narrower than the local resolver's bare-path matching: a bare `user/repo` is statically indistinguishable from a git shorthand at this layer, and matching it would kill the fast path for every project with git dependencies, so protocol-carrying and URL specs stay on the fast path.
- `pnpm.overrides` entries are scanned with the same predicate: an override mapping to a local file spec redirects every matching dependency in the graph to that directory, so it has the same blind spot as a direct local file dependency. Registry and `link:` overrides keep the fast path.
- The option is caller-scoped on purpose. `verifyDepsBeforeRun` also consumes `checkDepsStatus`, and treating `file:` deps as always stale there would force a reinstall before every `pnpm run`. Its behavior is unchanged, and a regression test pins that.
- pacquet port in the same commit: `check_optimistic_repeat_install` bails unconditionally on `file:` specifiers, because its only caller is the install command, the one consumer that sets the flag upstream. `link:` specifiers are excluded on both sides: they are symlinked, so changes inside them flow through without a reinstall.

## Why

Both branches of `checkDepsStatus` are blind to content changes inside a `file:` dependency. The workspace branch exits early with `upToDate: true` when no project manifest's mtime moved, without ever reaching `linkedPackagesAreUpToDate`. The non-workspace branch exits at the manifest-vs-lockfile mtime gate the same way. Editing a source file inside a `file:` dependency bumps neither, so the fast path can never see it; the fix has to bail before those gates rather than refine them. This is the fix shape (a) I proposed in my diagnosis on the issue thread ([comment](https://github.com/pnpm/pnpm/issues/11795#issuecomment-4504177744)): the cost is a full resolution on repeat installs only for projects that declare `file:` dependencies, which is exactly what v10 did.

The manifest-only comparison in `@pnpm/lockfile.verification` (`allProjectsAreUpToDate`) is intentional for the install-proper path and asserted by its tests, so this PR leaves it untouched.

## Checks

- `pnpm --filter @pnpm/deps.status test test/checkDepsStatus.test.ts` (31 passed, 13 new)
- `pnpm --filter @pnpm/deps.status run compile` and `pnpm --filter @pnpm/installing.commands run compile` (tsgo + eslint clean)
- `cargo test -p pacquet-package-manager optimistic_repeat_install` (51 passed, 7 new; run in a rust:1.95.0 container)
- `cargo fmt --check -p pacquet-package-manager`
- `RUSTDOCFLAGS="-D warnings" cargo doc -p pacquet-package-manager --no-deps`

---
Written by an agent (Claude Code, claude-fable-5).

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
This commit is contained in:
Truffle
2026-06-16 13:31:45 -07:00
committed by GitHub
parent 30c7590a26
commit 6d35338691
11 changed files with 1417 additions and 7 deletions

View File

@@ -0,0 +1,7 @@
---
"@pnpm/deps.status": patch
"@pnpm/installing.commands": patch
"pnpm": patch
---
`pnpm install` detects changes inside local file dependencies again. The optimistic repeat-install fast path only tracks manifest and lockfile modification times, so edits inside a local dependency's directory (or a repacked local tarball) were reported as "Already up to date". Projects with local file dependencies (`file:` and bare local path or tarball specifiers, declared directly or through `pnpm.overrides`) now always run a full install, which refetches those dependencies, matching pnpm v10 behavior [#11795](https://github.com/pnpm/pnpm/issues/11795).

View File

@@ -200,6 +200,7 @@
"msgpackr",
"msvc",
"msys",
"mtimes",
"musleabihf",
"mycomp",
"mycompany",
@@ -298,6 +299,7 @@
"redownload",
"refclone",
"refetched",
"refetches",
"reflattened",
"reflink",
"reflinked",

View File

@@ -33,6 +33,8 @@
".test": "cross-env NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules --disable-warning=ExperimentalWarning --disable-warning=DEP0169\" jest"
},
"dependencies": {
"@pnpm/catalogs.resolver": "workspace:*",
"@pnpm/catalogs.types": "workspace:*",
"@pnpm/config.parse-overrides": "workspace:*",
"@pnpm/config.reader": "workspace:*",
"@pnpm/constants": "workspace:*",

View File

@@ -2,6 +2,8 @@ import fs from 'node:fs'
import path from 'node:path'
import util from 'node:util'
import { resolveFromCatalog } from '@pnpm/catalogs.resolver'
import type { Catalogs } from '@pnpm/catalogs.types'
import { parseOverrides } from '@pnpm/config.parse-overrides'
import type { Config, ConfigContext } from '@pnpm/config.reader'
import { MANIFEST_BASE_NAMES, WANTED_LOCKFILE } from '@pnpm/constants'
@@ -27,11 +29,13 @@ import {
} from '@pnpm/lockfile.verification'
import { globalWarn, logger } from '@pnpm/logger'
import type { WorkspacePackages } from '@pnpm/resolving.resolver-base'
import type {
DependencyManifest,
Project,
ProjectId,
ProjectManifest,
import {
DEPENDENCIES_FIELDS,
type DependencyManifest,
type IncludedDependencies,
type Project,
type ProjectId,
type ProjectManifest,
} from '@pnpm/types'
import { findWorkspaceProjectsNoCheck } from '@pnpm/workspace.projects-reader'
import { loadWorkspaceState, updateWorkspaceState, WORKSPACE_STATE_SETTING_KEYS, type WorkspaceState, type WorkspaceStateSettings } from '@pnpm/workspace.state'
@@ -68,6 +72,26 @@ export type CheckDepsStatusOptions = Pick<Config,
ignoreFilteredInstallCache?: boolean
ignoredWorkspaceStateSettings?: Array<keyof WorkspaceStateSettings>
pnpmfile: string[]
/**
* The checks below only track manifest and lockfile mtimes, so edits inside
* a local file dependency's directory (or a repacked local tarball) go
* unnoticed. Callers that skip the install entirely when this check reports
* up-to-date must set this so that projects with local file dependencies
* (`file:` and bare local path/tarball specifiers) always run a real
* install, which refetches those dependencies
* (https://github.com/pnpm/pnpm/issues/11795).
*/
treatLocalFileDepsAsOutdated?: boolean
/**
* Which dependency groups the current install materializes. Local file
* dependencies in an excluded group (for example `devDependencies` under
* `--prod`) are not installed, so they don't force the
* `treatLocalFileDepsAsOutdated` bail-out. A change to these flags between
* installs is caught separately by the workspace state settings comparison
* (`dev`/`optional`/`production` are part of
* `WORKSPACE_STATE_SETTING_KEYS`).
*/
include?: IncludedDependencies
/**
* When git-branch lockfiles are enabled, the wanted lockfile lives at
* `pnpm-lock.<branch>.yaml`, so a missing `pnpm-lock.yaml` is the steady
@@ -141,6 +165,46 @@ async function _checkDepsStatus (opts: CheckDepsStatusOptions, workspaceState: W
workspaceDir,
} = opts
// This check must run before the node-linker=pnp early return below:
// that return reports up-to-date because verify-deps-before-run cannot
// inspect a PnP install, but for the optimistic repeat-install caller
// (the only one setting this flag) "up-to-date" would skip the install
// and break the local-file-deps guarantee.
if (opts.treatLocalFileDepsAsOutdated) {
const manifests = allProjects?.map(({ manifest }) => manifest) ?? []
// `rootProjectManifest` is tracked separately from `allProjects` and the
// recursive project list can omit the workspace root (for example when
// `includeWorkspaceRoot` is false), so scan it too unless `allProjects`
// already covers it.
if (rootProjectManifest != null && !allProjects?.some(({ rootDir }) => rootDir === rootProjectManifestDir)) {
manifests.push(rootProjectManifest)
}
const localFileDep = findLocalFileDep(manifests, opts.include, catalogs)
if (localFileDep != null) {
return {
upToDate: false,
issue: `The dependency "${localFileDep}" is a local file dependency and its contents may have changed`,
workspaceState,
}
}
const localFileOverride = findLocalFileOverride(opts.overrides, catalogs)
if (localFileOverride != null) {
return {
upToDate: false,
issue: `The override "${localFileOverride}" maps to a local file dependency and its contents may have changed`,
workspaceState,
}
}
const localFileExtension = findLocalFilePackageExtension(opts.packageExtensions, opts.include, catalogs)
if (localFileExtension != null) {
return {
upToDate: false,
issue: `The package extension "${localFileExtension}" injects a local file dependency and its contents may have changed`,
workspaceState,
}
}
}
if (nodeLinker === 'pnp') {
globalWarn('verify-deps-before-run does not work with node-linker=pnp')
return { upToDate: true, workspaceState: undefined }
@@ -622,6 +686,117 @@ async function assertWantedLockfileUpToDate (
}
}
/**
* Returns the name of the first dependency declared with a local file
* specifier in any of the given manifests, or `undefined` when there is none.
* `link:` dependencies are excluded: they are symlinked, so changes inside
* them flow through without a reinstall. Dependency groups excluded from the
* current install (per `include`) are skipped: their local file dependencies
* are not installed, so their contents cannot be stale. `catalog:` specs are
* dereferenced through the catalogs config: the catalog resolver only bans
* the `workspace:`, `link:`, and `file:` protocols, so a catalog entry can
* still hold a bare local path (`../lib`, `vendor/pkg.tgz`) that resolves to
* a local file dependency.
*/
function findLocalFileDep (manifests: ProjectManifest[], include?: IncludedDependencies, catalogs?: Catalogs): string | undefined {
for (const manifest of manifests) {
for (const depField of DEPENDENCIES_FIELDS) {
if (include?.[depField] === false) continue
const depName = findLocalFileDepInRecord(manifest[depField], catalogs)
if (depName != null) return depName
}
}
return undefined
}
/**
* Returns the name of the first dependency in `deps` declared with (or
* resolving through a catalog to) a local file specifier, or `undefined`.
*/
function findLocalFileDepInRecord (deps: Record<string, string> | undefined, catalogs?: Catalogs): string | undefined {
if (deps == null) return undefined
for (const [depName, spec] of Object.entries(deps)) {
// A malformed manifest may carry a non-string spec; skip it rather
// than throw — checkDepsStatus() must never crash.
if (typeof spec !== 'string') continue
if (isLocalFileSpec(spec)) return depName
// Only catalog: specs consult the catalogs, so skip the lookup for
// everything else to keep the optimistic fast path cheap.
if (!spec.startsWith('catalog:')) continue
const catalogResult = resolveFromCatalog(catalogs ?? {}, { alias: depName, bareSpecifier: spec })
if (catalogResult.type === 'found' && isLocalFileSpec(catalogResult.resolution.specifier)) return depName
}
return undefined
}
/**
* Returns the selector of the first `packageExtensions` entry that injects a
* local file dependency, or `undefined` when there is none. Package
* extensions are merged into matching packages' manifests by a read-package
* hook during install, so a `file:`/local-path/tarball spec added there has
* the same content-change blind spot as a direct local file dependency
* without appearing in any project manifest. Only `dependencies` and
* `optionalDependencies` are scanned: peer dependencies are resolved from the
* graph rather than fetched, so a local spec there is never installed.
*/
function findLocalFilePackageExtension (packageExtensions: CheckDepsStatusOptions['packageExtensions'], include?: IncludedDependencies, catalogs?: Catalogs): string | undefined {
if (packageExtensions == null) return undefined
for (const [selector, extension] of Object.entries(packageExtensions)) {
if (findLocalFileDepInRecord(extension.dependencies, catalogs) != null) return selector
if (include?.optionalDependencies === false) continue
if (findLocalFileDepInRecord(extension.optionalDependencies, catalogs) != null) return selector
}
return undefined
}
/**
* Returns the selector of the first override that maps to a local file
* specifier, or `undefined` when there is none. An override redirects every
* matching dependency in the graph to its specifier, so a local file override
* makes the installed contents depend on that directory or tarball the same
* way a direct local file dependency does. Overrides are run through
* `parseOverrides` so `catalog:` specs are dereferenced before the check.
* `parseOverrides` throws on a misconfigured catalog or invalid selector;
* that propagates to the outer catch in `checkDepsStatus`, which reports
* not-up-to-date, and the resulting full install surfaces the same error.
*/
function findLocalFileOverride (overrides: Record<string, string> | undefined, catalogs?: Catalogs): string | undefined {
if (overrides == null || isEmpty(overrides)) return undefined
return parseOverrides(overrides, catalogs)
.find(({ newBareSpecifier }) => isLocalFileSpec(newBareSpecifier))?.selector
}
const LOCAL_PATH_PREFIX = /^(?:[./\\]|~[/\\]|[a-z]:)/i
const LOCAL_TARBALL_EXTENSION = /\.(?:tgz|tar\.gz|tar)$/i
/**
* Whether the specifier resolves to a local directory or tarball whose
* contents can change without any manifest or lockfile mtime moving: the
* `file:` protocol, path-prefixed specs (`./`, `../`, `~/`, absolute POSIX
* paths, and Windows drive paths — including drive-relative ones like
* `C:dir`, matching the local resolver's `isFilespec`), and bare tarball
* file names.
*
* Deliberately narrower than the local resolver's bare-path matching: a bare
* `dir/file.tgz`-less path like `user/repo` is statically indistinguishable
* from a git shorthand at this layer, and matching it would disable the
* repeat-install fast path for every project with git dependencies. Such
* specs (and anything else carrying a protocol or URL) stay on the fast
* path. `catalog:` specs also return false here — callers dereference them
* through the catalogs config first, because a catalog entry may hold a
* bare local path (the catalog resolver only bans the `workspace:`,
* `link:`, and `file:` protocols).
*/
function isLocalFileSpec (spec: string): boolean {
if (spec.startsWith('file:')) return true
if (LOCAL_PATH_PREFIX.test(spec)) return true
if (spec.includes(':')) return false
// A `#` here means a hosted-git shorthand committish (`user/repo#release.tgz`),
// not a local tarball — the `file:` and path-prefixed cases already returned above.
if (spec.includes('#')) return false
return LOCAL_TARBALL_EXTENSION.test(spec)
}
function throwLockfileNotFound (wantedLockfileDir: string): never {
throw new PnpmError('RUN_CHECK_DEPS_LOCKFILE_NOT_FOUND', `Cannot find a lockfile in ${wantedLockfileDir}`, {
hint: 'Run `pnpm install` to create the lockfile',

View File

@@ -790,3 +790,467 @@ describe('checkDepsStatus - missing wanted lockfile fallback', () => {
})
})
})
describe('checkDepsStatus - treatLocalFileDepsAsOutdated', () => {
beforeEach(() => {
jest.resetModules()
jest.clearAllMocks()
})
const currentLockfile = {
lockfileVersion: '9.0',
importers: { '.': { specifiers: {} } },
} as unknown as LockfileObject
const mockWorkspaceState = (lastValidatedTimestamp: number): WorkspaceState => ({
lastValidatedTimestamp,
pnpmfiles: [],
settings: {
excludeLinksFromLockfile: false,
linkWorkspacePackages: true,
preferWorkspacePackages: true,
},
projects: {},
filteredInstall: false,
})
function mockUpToDateSingleProjectStats (lastValidatedTimestamp: number): void {
jest.mocked(fsUtils.safeStat).mockImplementation(async () => ({
mtime: new Date(lastValidatedTimestamp - 10_000),
mtimeMs: lastValidatedTimestamp - 10_000,
} as Stats))
jest.mocked(fsUtils.safeStatSync).mockReturnValue(undefined)
jest.mocked(statManifestFileUtils.statManifestFile).mockImplementation(async () => ({
mtime: new Date(lastValidatedTimestamp - 20_000),
mtimeMs: lastValidatedTimestamp - 20_000,
} as Stats))
jest.mocked(lockfileFs.readCurrentLockfile).mockImplementation(async () => currentLockfile)
jest.mocked(lockfileFs.readWantedLockfile).mockResolvedValue(null)
}
it('returns upToDate: false when the root manifest has a file: dependency', async () => {
const lastValidatedTimestamp = Date.now() - 10_000
jest.mocked(loadWorkspaceState).mockReturnValue(mockWorkspaceState(lastValidatedTimestamp))
const opts: CheckDepsStatusOptions = {
rootProjectManifest: {
dependencies: { foo: 'file:../foo' },
},
rootProjectManifestDir: '/project',
pnpmfile: [],
treatLocalFileDepsAsOutdated: true,
...mockWorkspaceState(lastValidatedTimestamp).settings,
}
const result = await checkDepsStatus(opts)
expect(result.upToDate).toBe(false)
expect(result.issue).toBe('The dependency "foo" is a local file dependency and its contents may have changed')
})
it('returns upToDate: false when a workspace project has a file: dependency', async () => {
const lastValidatedTimestamp = Date.now() - 10_000
jest.mocked(loadWorkspaceState).mockReturnValue(mockWorkspaceState(lastValidatedTimestamp))
const opts: CheckDepsStatusOptions = {
allProjects: [
{
rootDir: '/workspace' as ProjectRootDir,
rootDirRealPath: '/workspace' as ProjectRootDirRealPath,
manifest: { name: 'root', version: '1.0.0' },
writeProjectManifest: async () => {},
},
{
rootDir: '/workspace/packages/bar' as ProjectRootDir,
rootDirRealPath: '/workspace/packages/bar' as ProjectRootDirRealPath,
manifest: {
name: 'bar',
version: '1.0.0',
devDependencies: { tar: 'file:./vendor/tar.tgz' },
},
writeProjectManifest: async () => {},
},
],
workspaceDir: '/workspace',
sharedWorkspaceLockfile: true,
rootProjectManifest: { name: 'root', version: '1.0.0' },
rootProjectManifestDir: '/workspace',
pnpmfile: [],
treatLocalFileDepsAsOutdated: true,
...mockWorkspaceState(lastValidatedTimestamp).settings,
}
const result = await checkDepsStatus(opts)
expect(result.upToDate).toBe(false)
expect(result.issue).toBe('The dependency "tar" is a local file dependency and its contents may have changed')
})
it('returns upToDate: false when the root manifest has a file: dependency but allProjects omits the root', async () => {
const lastValidatedTimestamp = Date.now() - 10_000
jest.mocked(loadWorkspaceState).mockReturnValue(mockWorkspaceState(lastValidatedTimestamp))
const opts: CheckDepsStatusOptions = {
allProjects: [
{
rootDir: '/workspace/packages/bar' as ProjectRootDir,
rootDirRealPath: '/workspace/packages/bar' as ProjectRootDirRealPath,
manifest: { name: 'bar', version: '1.0.0' },
writeProjectManifest: async () => {},
},
],
workspaceDir: '/workspace',
sharedWorkspaceLockfile: true,
rootProjectManifest: {
name: 'root',
version: '1.0.0',
dependencies: { foo: 'file:../foo' },
},
rootProjectManifestDir: '/workspace',
pnpmfile: [],
treatLocalFileDepsAsOutdated: true,
...mockWorkspaceState(lastValidatedTimestamp).settings,
}
const result = await checkDepsStatus(opts)
expect(result.upToDate).toBe(false)
expect(result.issue).toBe('The dependency "foo" is a local file dependency and its contents may have changed')
})
it('reports up-to-date when there is a file: dependency but the option is not set', async () => {
const lastValidatedTimestamp = Date.now() - 10_000
jest.mocked(loadWorkspaceState).mockReturnValue(mockWorkspaceState(lastValidatedTimestamp))
mockUpToDateSingleProjectStats(lastValidatedTimestamp)
const opts: CheckDepsStatusOptions = {
rootProjectManifest: {
dependencies: { foo: 'file:../foo' },
},
rootProjectManifestDir: '/project',
pnpmfile: [],
...mockWorkspaceState(lastValidatedTimestamp).settings,
}
const result = await checkDepsStatus(opts)
expect(result.upToDate).toBe(true)
})
it('does not report link: and registry dependencies as outdated', async () => {
const lastValidatedTimestamp = Date.now() - 10_000
jest.mocked(loadWorkspaceState).mockReturnValue(mockWorkspaceState(lastValidatedTimestamp))
mockUpToDateSingleProjectStats(lastValidatedTimestamp)
const opts: CheckDepsStatusOptions = {
rootProjectManifest: {
dependencies: {
foo: 'link:../foo',
bar: '^1.0.0',
},
},
rootProjectManifestDir: '/project',
pnpmfile: [],
treatLocalFileDepsAsOutdated: true,
...mockWorkspaceState(lastValidatedTimestamp).settings,
}
const result = await checkDepsStatus(opts)
expect(result.upToDate).toBe(true)
})
it.each([
['a bare local tarball path', 'vendor/pkg.tgz'],
['a relative directory path', '../sibling-dir'],
['a home-relative path', '~/pkgs/foo'],
['an absolute path', '/abs/path/foo'],
['an absolute Windows drive path', 'C:\\pkgs\\foo'],
['a drive-relative Windows path', 'C:pkgs'],
])('returns upToDate: false when the root manifest has %s dependency', async (_desc, spec) => {
const lastValidatedTimestamp = Date.now() - 10_000
jest.mocked(loadWorkspaceState).mockReturnValue(mockWorkspaceState(lastValidatedTimestamp))
const opts: CheckDepsStatusOptions = {
rootProjectManifest: {
dependencies: { foo: spec },
},
rootProjectManifestDir: '/project',
pnpmfile: [],
treatLocalFileDepsAsOutdated: true,
...mockWorkspaceState(lastValidatedTimestamp).settings,
}
const result = await checkDepsStatus(opts)
expect(result.upToDate).toBe(false)
expect(result.issue).toBe('The dependency "foo" is a local file dependency and its contents may have changed')
})
it('returns upToDate: false when an override maps to a local file dependency', async () => {
const lastValidatedTimestamp = Date.now() - 10_000
jest.mocked(loadWorkspaceState).mockReturnValue(mockWorkspaceState(lastValidatedTimestamp))
const opts: CheckDepsStatusOptions = {
rootProjectManifest: {
dependencies: { foo: '^1.0.0' },
},
rootProjectManifestDir: '/project',
overrides: { bar: 'file:../bar' },
pnpmfile: [],
treatLocalFileDepsAsOutdated: true,
...mockWorkspaceState(lastValidatedTimestamp).settings,
}
const result = await checkDepsStatus(opts)
expect(result.upToDate).toBe(false)
expect(result.issue).toBe('The override "bar" maps to a local file dependency and its contents may have changed')
})
it('does not report registry and link: overrides as outdated', async () => {
const lastValidatedTimestamp = Date.now() - 10_000
const overrides = { bar: '^2.0.0', baz: 'link:../baz' }
const workspaceState = mockWorkspaceState(lastValidatedTimestamp)
workspaceState.settings = { ...workspaceState.settings, overrides }
jest.mocked(loadWorkspaceState).mockReturnValue(workspaceState)
mockUpToDateSingleProjectStats(lastValidatedTimestamp)
const opts: CheckDepsStatusOptions = {
rootProjectManifest: {
dependencies: { foo: '^1.0.0' },
},
rootProjectManifestDir: '/project',
pnpmfile: [],
treatLocalFileDepsAsOutdated: true,
...workspaceState.settings,
}
const result = await checkDepsStatus(opts)
expect(result.upToDate).toBe(true)
})
it('does not report git, remote tarball, and tilde range dependencies as outdated', async () => {
const lastValidatedTimestamp = Date.now() - 10_000
jest.mocked(loadWorkspaceState).mockReturnValue(mockWorkspaceState(lastValidatedTimestamp))
mockUpToDateSingleProjectStats(lastValidatedTimestamp)
const opts: CheckDepsStatusOptions = {
rootProjectManifest: {
dependencies: {
foo: 'user/repo',
bar: 'github:user/repo',
baz: 'https://example.com/pkg.tgz',
qux: '~1.2.3',
// A git shorthand whose committish ends in .tgz must not be
// mistaken for a local tarball.
quux: 'user/repo#release.tgz',
},
},
rootProjectManifestDir: '/project',
pnpmfile: [],
treatLocalFileDepsAsOutdated: true,
...mockWorkspaceState(lastValidatedTimestamp).settings,
}
const result = await checkDepsStatus(opts)
expect(result.upToDate).toBe(true)
})
it('returns upToDate: false for a local file dependency even when nodeLinker is pnp', async () => {
const lastValidatedTimestamp = Date.now() - 10_000
jest.mocked(loadWorkspaceState).mockReturnValue(mockWorkspaceState(lastValidatedTimestamp))
const opts: CheckDepsStatusOptions = {
rootProjectManifest: {
dependencies: { foo: 'file:../foo' },
},
rootProjectManifestDir: '/project',
nodeLinker: 'pnp',
pnpmfile: [],
treatLocalFileDepsAsOutdated: true,
...mockWorkspaceState(lastValidatedTimestamp).settings,
}
const result = await checkDepsStatus(opts)
expect(result.upToDate).toBe(false)
expect(result.issue).toBe('The dependency "foo" is a local file dependency and its contents may have changed')
})
it('reports up-to-date when the only file: dependency is in a group excluded from the install', async () => {
const lastValidatedTimestamp = Date.now() - 10_000
jest.mocked(loadWorkspaceState).mockReturnValue(mockWorkspaceState(lastValidatedTimestamp))
mockUpToDateSingleProjectStats(lastValidatedTimestamp)
const opts: CheckDepsStatusOptions = {
rootProjectManifest: {
optionalDependencies: { foo: 'file:../foo' },
},
rootProjectManifestDir: '/project',
pnpmfile: [],
treatLocalFileDepsAsOutdated: true,
include: {
dependencies: true,
devDependencies: true,
optionalDependencies: false,
},
...mockWorkspaceState(lastValidatedTimestamp).settings,
}
const result = await checkDepsStatus(opts)
expect(result.upToDate).toBe(true)
})
it('returns upToDate: false for a file: dependency in a group included in the install', async () => {
const lastValidatedTimestamp = Date.now() - 10_000
jest.mocked(loadWorkspaceState).mockReturnValue(mockWorkspaceState(lastValidatedTimestamp))
const opts: CheckDepsStatusOptions = {
rootProjectManifest: {
dependencies: { foo: 'file:../foo' },
},
rootProjectManifestDir: '/project',
pnpmfile: [],
treatLocalFileDepsAsOutdated: true,
include: {
dependencies: true,
devDependencies: false,
optionalDependencies: false,
},
...mockWorkspaceState(lastValidatedTimestamp).settings,
}
const result = await checkDepsStatus(opts)
expect(result.upToDate).toBe(false)
expect(result.issue).toBe('The dependency "foo" is a local file dependency and its contents may have changed')
})
it('skips non-string dependency specs in malformed manifests without throwing', async () => {
const lastValidatedTimestamp = Date.now() - 10_000
jest.mocked(loadWorkspaceState).mockReturnValue(mockWorkspaceState(lastValidatedTimestamp))
const opts: CheckDepsStatusOptions = {
rootProjectManifest: {
dependencies: {
broken: 42 as unknown as string,
foo: 'file:../foo',
},
},
rootProjectManifestDir: '/project',
pnpmfile: [],
treatLocalFileDepsAsOutdated: true,
...mockWorkspaceState(lastValidatedTimestamp).settings,
}
const result = await checkDepsStatus(opts)
expect(result.upToDate).toBe(false)
expect(result.issue).toBe('The dependency "foo" is a local file dependency and its contents may have changed')
})
it('returns upToDate: false when a catalog: dependency resolves to a bare local path', async () => {
const lastValidatedTimestamp = Date.now() - 10_000
jest.mocked(loadWorkspaceState).mockReturnValue(mockWorkspaceState(lastValidatedTimestamp))
const opts: CheckDepsStatusOptions = {
rootProjectManifest: {
dependencies: { foo: 'catalog:' },
},
rootProjectManifestDir: '/project',
catalogs: { default: { foo: '../foo' } },
pnpmfile: [],
treatLocalFileDepsAsOutdated: true,
...mockWorkspaceState(lastValidatedTimestamp).settings,
}
const result = await checkDepsStatus(opts)
expect(result.upToDate).toBe(false)
expect(result.issue).toBe('The dependency "foo" is a local file dependency and its contents may have changed')
})
it('does not report a catalog: dependency resolving to a registry range as outdated', async () => {
const lastValidatedTimestamp = Date.now() - 10_000
const catalogs = { default: { foo: '^1.0.0' } }
const workspaceState = mockWorkspaceState(lastValidatedTimestamp)
workspaceState.settings = { ...workspaceState.settings, catalogs }
jest.mocked(loadWorkspaceState).mockReturnValue(workspaceState)
mockUpToDateSingleProjectStats(lastValidatedTimestamp)
const opts: CheckDepsStatusOptions = {
rootProjectManifest: {
dependencies: { foo: 'catalog:' },
},
rootProjectManifestDir: '/project',
catalogs,
pnpmfile: [],
treatLocalFileDepsAsOutdated: true,
...workspaceState.settings,
}
const result = await checkDepsStatus(opts)
expect(result.upToDate).toBe(true)
})
it('returns upToDate: false when an override maps through a catalog to a local path', async () => {
const lastValidatedTimestamp = Date.now() - 10_000
jest.mocked(loadWorkspaceState).mockReturnValue(mockWorkspaceState(lastValidatedTimestamp))
const opts: CheckDepsStatusOptions = {
rootProjectManifest: {
dependencies: { foo: '^1.0.0' },
},
rootProjectManifestDir: '/project',
overrides: { bar: 'catalog:' },
catalogs: { default: { bar: './vendor/bar' } },
pnpmfile: [],
treatLocalFileDepsAsOutdated: true,
...mockWorkspaceState(lastValidatedTimestamp).settings,
}
const result = await checkDepsStatus(opts)
expect(result.upToDate).toBe(false)
expect(result.issue).toBe('The override "bar" maps to a local file dependency and its contents may have changed')
})
it('returns upToDate: false when a packageExtension injects a local file dependency', async () => {
const lastValidatedTimestamp = Date.now() - 10_000
jest.mocked(loadWorkspaceState).mockReturnValue(mockWorkspaceState(lastValidatedTimestamp))
const opts: CheckDepsStatusOptions = {
rootProjectManifest: {
dependencies: { foo: '^1.0.0' },
},
rootProjectManifestDir: '/project',
packageExtensions: {
'foo@1': { dependencies: { bar: 'file:../bar' } },
},
pnpmfile: [],
treatLocalFileDepsAsOutdated: true,
...mockWorkspaceState(lastValidatedTimestamp).settings,
}
const result = await checkDepsStatus(opts)
expect(result.upToDate).toBe(false)
expect(result.issue).toBe('The package extension "foo@1" injects a local file dependency and its contents may have changed')
})
it('does not report a packageExtension optionalDependency as outdated when optionals are excluded', async () => {
const lastValidatedTimestamp = Date.now() - 10_000
const packageExtensions = { 'foo@1': { optionalDependencies: { bar: 'file:../bar' } } }
const workspaceState = mockWorkspaceState(lastValidatedTimestamp)
workspaceState.settings = { ...workspaceState.settings, packageExtensions }
jest.mocked(loadWorkspaceState).mockReturnValue(workspaceState)
mockUpToDateSingleProjectStats(lastValidatedTimestamp)
const opts: CheckDepsStatusOptions = {
rootProjectManifest: {
dependencies: { foo: '^1.0.0' },
},
rootProjectManifestDir: '/project',
packageExtensions,
pnpmfile: [],
treatLocalFileDepsAsOutdated: true,
include: {
dependencies: true,
devDependencies: true,
optionalDependencies: false,
},
...workspaceState.settings,
}
const result = await checkDepsStatus(opts)
expect(result.upToDate).toBe(true)
})
})

View File

@@ -12,6 +12,12 @@
{
"path": "../../__utils__/prepare"
},
{
"path": "../../catalogs/resolver"
},
{
"path": "../../catalogs/types"
},
{
"path": "../../config/parse-overrides"
},

View File

@@ -186,6 +186,7 @@ export async function installDeps (
const { upToDate, wantedLockfileToRestore } = await checkDepsStatus({
...opts,
ignoreFilteredInstallCache: true,
treatLocalFileDepsAsOutdated: true,
})
if (upToDate && await restoreWantedLockfileIfMissing(wantedLockfileToRestore, opts)) {
if (opts.hooks?.customResolvers?.some(r => r.shouldRefreshResolution)) {

View File

@@ -382,3 +382,38 @@ test('a config-level dryRun does not turn add into a no-op', async () => {
const pkg = loadJsonFileSync<{ dependencies?: Record<string, string> }>(path.resolve('package.json'))
expect(pkg.dependencies).toStrictEqual({ 'is-positive': '1.0.0' })
})
// Covers https://github.com/pnpm/pnpm/issues/11795
test('repeat install refetches a file: dependency after its contents change', async () => {
prepareEmpty()
const localDepDir = path.resolve('..', 'local-dep')
fs.mkdirSync(localDepDir, { recursive: true })
fs.writeFileSync(path.join(localDepDir, 'package.json'), JSON.stringify({ name: 'local-dep', version: '1.0.0' }), 'utf8')
fs.writeFileSync(path.join(localDepDir, 'index.js'), 'v1', 'utf8')
fs.writeFileSync('package.json', JSON.stringify({
name: 'project',
version: '1.0.0',
dependencies: { 'local-dep': 'file:../local-dep' },
}), 'utf8')
await install.handler({
...DEFAULT_OPTS,
dir: process.cwd(),
optimisticRepeatInstall: true,
})
expect(fs.readFileSync('node_modules/local-dep/index.js', 'utf8')).toBe('v1')
// A short delay so the edited file's mtime is unambiguously newer.
await delay(200)
fs.writeFileSync(path.join(localDepDir, 'index.js'), 'v2', 'utf8')
// Without the local-file-deps guard the optimistic fast path would
// report "Already up to date" here and leave node_modules at v1.
await install.handler({
...DEFAULT_OPTS,
dir: process.cwd(),
optimisticRepeatInstall: true,
})
expect(fs.readFileSync('node_modules/local-dep/index.js', 'utf8')).toBe('v2')
})

View File

@@ -31,8 +31,15 @@
//! the pnpmfile branch of `patchesOrHooksAreModified` (an added, removed,
//! or edited workspace pnpmfile invalidates the fast path; plugin
//! pnpmfiles from config dependencies are covered by the
//! `config_dependencies` comparison instead of the mtime check). The
//! `isLocalFileDepUpdated` branch of `linkedPackagesAreUpToDate` is NOT
//! `config_dependencies` comparison instead of the mtime check), and the
//! local-file-dependency bail (upstream's `treatLocalFileDepsAsOutdated`
//! option, set by `installDeps`): no tracked mtime covers the *contents*
//! of a local file dependency (a `file:` specifier or a bare local
//! path/tarball spec, declared directly or through a `pnpm.overrides`
//! entry), so projects declaring one always take the
//! full install path, which refetches those dependencies
//! (pnpm/pnpm#11795). The `isLocalFileDepUpdated` branch of
//! `linkedPackagesAreUpToDate` is NOT
//! ported here. When this function returns `Decision::Skipped` the caller
//! proceeds with the full install path, which still has its own freshness
//! guards (`check_lockfile_freshness`, the no-op short-circuit). Remaining
@@ -54,6 +61,7 @@ use std::{
time::SystemTime,
};
use pacquet_catalogs_resolver::{CatalogResolutionResult, WantedDependency, resolve_from_catalog};
use pacquet_catalogs_types::Catalogs;
use pacquet_config::{Config, LinkWorkspacePackages, NodeLinker};
use pacquet_lockfile::{ImporterDepVersion, Lockfile, MaybeLazyLockfile, ProjectSnapshot};
@@ -152,6 +160,29 @@ pub fn check_optimistic_repeat_install(check: &OptimisticRepeatInstallCheck<'_>)
return Decision::Skipped { reason: "no workspace state on disk" };
};
// Unconditional where upstream gates it behind
// `treatLocalFileDepsAsOutdated`: the only caller here is the
// install command — the one consumer that sets the flag upstream.
if has_local_file_dep(project_manifests, included, catalogs) {
return Decision::Skipped {
reason: "a dependency is a local file dependency and its contents may have changed",
};
}
match has_local_file_override(config, catalogs) {
Ok(true) => {
return Decision::Skipped {
reason: "an override maps to a local file dependency and its contents may have changed",
};
}
Err(reason) => return Decision::Skipped { reason },
Ok(false) => {}
}
if has_local_file_package_extension(config, included, catalogs) {
return Decision::Skipped {
reason: "a package extension injects a local file dependency and its contents may have changed",
};
}
if !settings_match(&state, config, node_linker, included) {
return Decision::Skipped { reason: "settings drift" };
}
@@ -282,6 +313,182 @@ pub fn check_optimistic_repeat_install(check: &OptimisticRepeatInstallCheck<'_>)
}
}
/// Whether any project declares a dependency with a local file
/// specifier in `dependencies`, `devDependencies`, or
/// `optionalDependencies`. Port of upstream's `findLocalFileDep` in
/// `deps/status/src/checkDepsStatus.ts`
/// (<https://github.com/pnpm/pnpm/issues/11795>). `link:` specifiers
/// don't count: they are symlinked, so changes inside them flow
/// through without a reinstall. Groups excluded from the current
/// install (per `included`) are skipped: their local file dependencies
/// are not installed, so their contents cannot be stale. A change to
/// the include flags between installs is caught separately by
/// `settings_match`. `catalog:` specs are dereferenced through the
/// workspace catalogs: the catalog resolver only bans the `workspace:`,
/// `link:`, and `file:` protocols, so a catalog entry can still hold a
/// bare local path (`../lib`, `vendor/pkg.tgz`) that resolves to a
/// local file dependency.
fn has_local_file_dep(
project_manifests: &[(PathBuf, &PackageManifest)],
included: IncludedDependencies,
catalogs: &Catalogs,
) -> bool {
let fields: [(&str, bool); 3] = [
("dependencies", included.dependencies),
("devDependencies", included.dev_dependencies),
("optionalDependencies", included.optional_dependencies),
];
project_manifests.iter().any(|(_, manifest)| {
fields.iter().any(|(field, group_included)| {
*group_included
&& manifest.value().get(*field).and_then(|value| value.as_object()).is_some_and(
|deps| {
deps.iter().any(|(alias, spec)| {
spec.as_str().is_some_and(|spec| {
is_local_file_spec(spec)
|| catalog_resolves_to_local_file(catalogs, alias, spec)
})
})
},
)
})
})
}
/// Whether a `catalog:` spec dereferences (through the workspace
/// catalogs) to a local file specifier. Non-catalog specs and
/// misconfigured catalog entries return `false`: the former never
/// consult the catalogs, and the latter fail the full install with the
/// proper error anyway — the fast path only needs to not report
/// up-to-date for a *valid* catalog entry holding a local path.
fn catalog_resolves_to_local_file(catalogs: &Catalogs, alias: &str, spec: &str) -> bool {
// `resolve_from_catalog` returns `Unused` for any non-`catalog:` spec, so
// short-circuit before allocating the owned `WantedDependency` it needs.
if !spec.starts_with("catalog:") {
return false;
}
match resolve_from_catalog(
catalogs,
&WantedDependency { alias: alias.to_string(), bare_specifier: spec.to_string() },
) {
CatalogResolutionResult::Found(found) => is_local_file_spec(&found.resolution.specifier),
_ => false,
}
}
/// Whether any `pnpm.overrides` entry maps to a local file specifier.
/// Port of upstream's `findLocalFileOverride` in
/// `deps/status/src/checkDepsStatus.ts`: an override redirects every
/// matching dependency in the graph to its specifier, so a local file
/// override makes the installed contents depend on that directory or
/// tarball the same way a direct local file dependency does. Overrides
/// are run through `parse_config_overrides` so `catalog:` specs are
/// dereferenced before the check. A parse failure (misconfigured
/// catalog, invalid selector) bails to the full install path with its
/// own distinct reason — not the local-file reason, which would
/// misattribute the cause — mirroring upstream, where `parseOverrides`
/// throws to `checkDepsStatus`'s outer catch and the caller falls back
/// to a full install.
fn has_local_file_override(config: &Config, catalogs: &Catalogs) -> Result<bool, &'static str> {
match crate::install::parse_config_overrides(config, catalogs) {
Ok(Some(overrides)) => {
Ok(overrides.iter().any(|entry| is_local_file_spec(&entry.new_bare_specifier)))
}
Ok(None) => Ok(false),
Err(_) => Err("pnpm.overrides cannot be parsed"),
}
}
/// Whether any `packageExtensions` entry injects a dependency with a
/// local file specifier. Package extensions are merged into matching
/// packages' manifests by the read-package hook during the full
/// install, so a `file:`/local-path/tarball spec added there has the
/// same content-change blind spot as a direct local file dependency
/// without appearing in any project manifest. Only `dependencies` and
/// `optionalDependencies` are scanned: peer dependencies are resolved
/// from the graph rather than fetched, so a local spec there is never
/// installed. `optionalDependencies` are skipped when the install
/// excludes them, mirroring `has_local_file_dep`.
fn has_local_file_package_extension(
config: &Config,
included: IncludedDependencies,
catalogs: &Catalogs,
) -> bool {
let Some(extensions) = config.package_extensions.as_ref() else {
return false;
};
extensions.values().any(|extension| {
let optional = included
.optional_dependencies
.then_some(extension.optional_dependencies.as_ref())
.flatten();
[extension.dependencies.as_ref(), optional].into_iter().flatten().any(|deps| {
deps.iter().any(|(alias, spec)| {
is_local_file_spec(spec) || catalog_resolves_to_local_file(catalogs, alias, spec)
})
})
})
}
/// Whether the specifier resolves to a local directory or tarball whose
/// contents can change without any manifest or lockfile mtime moving:
/// the `file:` protocol, path-prefixed specs (`./`, `../`, `~/`,
/// absolute POSIX paths, and Windows drive paths including
/// drive-relative ones like `c:dir`), and bare tarball file names.
/// Port of upstream's `isLocalFileSpec` in
/// `deps/status/src/checkDepsStatus.ts`.
///
/// Deliberately narrower than the local resolver's bare-path matching:
/// a bare path like `user/repo` is statically indistinguishable from a
/// git shorthand at this layer, and matching it would disable the
/// repeat-install fast path for every project with git dependencies.
/// Such specs (and anything else carrying a protocol or URL) stay on
/// the fast path. `catalog:` specs also return `false` here — callers
/// dereference them through the workspace catalogs first, because a
/// catalog entry may hold a bare local path (the catalog resolver only
/// bans the `workspace:`, `link:`, and `file:` protocols).
fn is_local_file_spec(spec: &str) -> bool {
if spec.starts_with("file:") {
return true;
}
if spec.starts_with(['.', '/', '\\'])
|| spec.starts_with("~/")
|| spec.starts_with(r"~\")
|| is_windows_drive_path(spec)
{
return true;
}
if spec.contains(':') {
return false;
}
// A `#` here means a hosted-git shorthand committish
// (`user/repo#release.tgz`), not a local tarball — the `file:` and
// path-prefixed cases already returned above.
if spec.contains('#') {
return false;
}
ends_with_ignore_ascii_case(spec, ".tgz")
|| ends_with_ignore_ascii_case(spec, ".tar.gz")
|| ends_with_ignore_ascii_case(spec, ".tar")
}
/// Case-insensitive (ASCII) suffix check that, unlike
/// `spec.to_ascii_lowercase().ends_with(suffix)`, does not allocate.
fn ends_with_ignore_ascii_case(spec: &str, suffix: &str) -> bool {
let spec = spec.as_bytes();
let suffix = suffix.as_bytes();
spec.len() >= suffix.len() && spec[spec.len() - suffix.len()..].eq_ignore_ascii_case(suffix)
}
/// `c:/...`, `c:\...`, or drive-relative `c:foo` — a Windows drive
/// path. No separator is required after the colon, matching the local
/// resolver's `isFilespec` (`resolving/local-resolver/src/parseBareSpecifier.ts`);
/// no registry protocol is a single letter, so `[a-z]:` is unambiguous.
fn is_windows_drive_path(spec: &str) -> bool {
let bytes = spec.as_bytes();
bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':'
}
/// Restore a missing `pnpm-lock.yaml` from the current lockfile before
/// the fast path reports "Already up to date", so the short-circuit
/// leaves the same on-disk contract a full install would (the full

View File

@@ -2,6 +2,7 @@ use super::{
Decision, OptimisticRepeatInstallCheck, check_optimistic_repeat_install, current_settings,
current_settings_with_catalogs,
};
use indexmap::IndexMap;
use pacquet_catalogs_types::Catalogs;
use pacquet_config::Config;
use pacquet_lockfile::{Lockfile, MaybeLazyLockfile};
@@ -100,6 +101,25 @@ fn setup_fresh_install(
project_name: &str,
project_version: &str,
manifest_extra_json: &str,
) -> (tempfile::TempDir, &'static Config, PackageManifest) {
setup_fresh_install_with_config(
config_kind,
project_name,
project_version,
manifest_extra_json,
|_| {},
)
}
/// Same as [`setup_fresh_install`] but applies `configure` to the
/// `Config` before the workspace-state snapshot is taken, so the
/// settings comparison sees the configured values as unchanged.
fn setup_fresh_install_with_config(
config_kind: pacquet_config::NodeLinker,
project_name: &str,
project_version: &str,
manifest_extra_json: &str,
configure: impl FnOnce(&mut Config),
) -> (tempfile::TempDir, &'static Config, PackageManifest) {
let dir = tempdir().unwrap();
let workspace_root = dir.path();
@@ -130,6 +150,7 @@ fn setup_fresh_install(
let mut config = Config::new();
config.modules_dir = workspace_root.join("node_modules");
configure(&mut config);
let config = Box::leak(Box::new(config));
// Pre-create the modules dir so the "missing node_modules" guard
// doesn't fire on the happy-path tests.
@@ -163,6 +184,490 @@ fn returns_up_to_date_when_state_and_manifests_agree() {
assert_eq!(decision, Decision::UpToDate);
}
/// A `file:` dependency must never short-circuit: nothing the fast
/// path stats covers the dependency's *contents*, so the full install
/// path has to run and refetch it
/// (<https://github.com/pnpm/pnpm/issues/11795>).
#[test]
fn returns_skipped_when_a_project_has_a_file_dependency() {
let (dir, config, manifest) = setup_fresh_install(
pacquet_config::NodeLinker::Isolated,
"root",
"1.0.0",
r#""dependencies":{"foo":"file:../foo"}"#,
);
let decision = check(
dir.path(),
config,
pacquet_config::NodeLinker::Isolated,
&[(dir.path().to_path_buf(), &manifest)],
);
assert!(
matches!(decision, Decision::Skipped { reason } if reason.contains("local file dependency")),
);
}
/// Same bail for a `file:` *tarball* in any dependency group — a
/// repacked `.tgz` bumps no manifest mtime either.
#[test]
fn returns_skipped_when_a_project_has_a_file_tarball_dev_dependency() {
let (dir, config, manifest) = setup_fresh_install(
pacquet_config::NodeLinker::Isolated,
"root",
"1.0.0",
r#""devDependencies":{"tar":"file:./vendor/tar.tgz"}"#,
);
let decision = check(
dir.path(),
config,
pacquet_config::NodeLinker::Isolated,
&[(dir.path().to_path_buf(), &manifest)],
);
assert!(
matches!(decision, Decision::Skipped { reason } if reason.contains("local file dependency")),
);
}
/// Bare local path and tarball specs resolve to local file dependencies
/// too — same bail as `file:`, for the same reason.
#[test]
fn returns_skipped_when_a_project_has_a_bare_local_path_dependency() {
for spec in
["vendor/pkg.tgz", "../sibling-dir", "~/pkgs/foo", "/abs/path/foo", "c:/pkgs/foo", "c:pkgs"]
{
let (dir, config, manifest) = setup_fresh_install(
pacquet_config::NodeLinker::Isolated,
"root",
"1.0.0",
&format!(r#""dependencies":{{"foo":"{spec}"}}"#),
);
let decision = check(
dir.path(),
config,
pacquet_config::NodeLinker::Isolated,
&[(dir.path().to_path_buf(), &manifest)],
);
assert!(
matches!(decision, Decision::Skipped { reason } if reason.contains("local file dependency")),
"spec {spec:?} must bail",
);
}
}
/// A local file dependency in a group excluded from the install must
/// not bail: the group isn't materialized, so its contents can't be
/// stale. A change to the include flags themselves is caught by the
/// settings comparison instead.
#[test]
fn returns_up_to_date_when_the_local_file_dependency_is_in_an_excluded_group() {
let (dir, config, manifest) = setup_fresh_install(
pacquet_config::NodeLinker::Isolated,
"root",
"1.0.0",
r#""optionalDependencies":{"foo":"file:../foo"}"#,
);
let included = IncludedDependencies {
dependencies: true,
dev_dependencies: true,
optional_dependencies: false,
};
// Re-stamp the state with the same include flags the check runs
// under, so the settings comparison passes and the include gate is
// what gets exercised.
let settings = current_settings(config, pacquet_config::NodeLinker::Isolated, included);
let mut projects = BTreeMap::new();
projects.insert(
dir.path().to_string_lossy().into_owned(),
ProjectEntry { name: Some("root".into()), version: Some("1.0.0".into()) },
);
write_state(dir.path(), now_millis(), settings, projects);
let decision = check_optimistic_repeat_install(&OptimisticRepeatInstallCheck {
workspace_root: dir.path(),
config,
node_linker: pacquet_config::NodeLinker::Isolated,
included,
project_manifests: &[(dir.path().to_path_buf(), &manifest)],
is_workspace_install: false,
lockfile: MaybeLazyLockfile::Loaded(None),
catalogs: &BTreeMap::default(),
});
assert_eq!(decision, Decision::UpToDate);
}
/// The include gate is per-group: a local file dependency in a group
/// that *is* installed still bails even when other groups are excluded.
#[test]
fn returns_skipped_when_the_local_file_dependency_is_in_an_included_group() {
let (dir, config, manifest) = setup_fresh_install(
pacquet_config::NodeLinker::Isolated,
"root",
"1.0.0",
r#""dependencies":{"foo":"file:../foo"}"#,
);
let included = IncludedDependencies {
dependencies: true,
dev_dependencies: false,
optional_dependencies: false,
};
let settings = current_settings(config, pacquet_config::NodeLinker::Isolated, included);
let mut projects = BTreeMap::new();
projects.insert(
dir.path().to_string_lossy().into_owned(),
ProjectEntry { name: Some("root".into()), version: Some("1.0.0".into()) },
);
write_state(dir.path(), now_millis(), settings, projects);
let decision = check_optimistic_repeat_install(&OptimisticRepeatInstallCheck {
workspace_root: dir.path(),
config,
node_linker: pacquet_config::NodeLinker::Isolated,
included,
project_manifests: &[(dir.path().to_path_buf(), &manifest)],
is_workspace_install: false,
lockfile: MaybeLazyLockfile::Loaded(None),
catalogs: &BTreeMap::default(),
});
assert!(
matches!(decision, Decision::Skipped { reason } if reason.contains("local file dependency")),
);
}
/// A `pnpm.overrides` entry mapping to a local file spec must bail the
/// same way a direct local file dependency does: the override redirects
/// every matching dependency in the graph to that directory, and
/// nothing the fast path stats covers its contents.
#[test]
fn returns_skipped_when_an_override_maps_to_a_local_file_dependency() {
let (dir, config, manifest) = setup_fresh_install_with_config(
pacquet_config::NodeLinker::Isolated,
"root",
"1.0.0",
r#""dependencies":{"foo":"^1.0.0"}"#,
|config| {
config.overrides =
Some(IndexMap::from([("bar".to_string(), "file:../bar".to_string())]));
},
);
let decision = check(
dir.path(),
config,
pacquet_config::NodeLinker::Isolated,
&[(dir.path().to_path_buf(), &manifest)],
);
assert!(
matches!(decision, Decision::Skipped { reason } if reason.contains("override")),
"decision was {decision:?}",
);
}
/// Registry and `link:` overrides keep the fast path: neither redirects
/// a dependency to contents only a refetch would pick up.
#[test]
fn returns_up_to_date_when_overrides_are_not_local_paths() {
let (dir, config, manifest) = setup_fresh_install_with_config(
pacquet_config::NodeLinker::Isolated,
"root",
"1.0.0",
r#""dependencies":{"foo":"^1.0.0"}"#,
|config| {
config.overrides = Some(IndexMap::from([
("bar".to_string(), "^2.0.0".to_string()),
("baz".to_string(), "link:../baz".to_string()),
]));
},
);
let decision = check(
dir.path(),
config,
pacquet_config::NodeLinker::Isolated,
&[(dir.path().to_path_buf(), &manifest)],
);
assert_eq!(decision, Decision::UpToDate);
}
/// A `packageExtensions` entry injecting a local file dependency must
/// bail: extensions are merged into matching packages' manifests during
/// the full install, so the spec never appears in a project manifest.
#[test]
fn returns_skipped_when_a_package_extension_injects_a_local_file_dependency() {
let (dir, config, manifest) = setup_fresh_install_with_config(
pacquet_config::NodeLinker::Isolated,
"root",
"1.0.0",
r#""dependencies":{"foo":"^1.0.0"}"#,
|config| {
config.package_extensions = Some(IndexMap::from([(
"foo@1".to_string(),
pacquet_config::PackageExtension {
dependencies: Some(BTreeMap::from([(
"bar".to_string(),
"file:../bar".to_string(),
)])),
..Default::default()
},
)]));
},
);
let decision = check(
dir.path(),
config,
pacquet_config::NodeLinker::Isolated,
&[(dir.path().to_path_buf(), &manifest)],
);
assert!(
matches!(decision, Decision::Skipped { reason } if reason.contains("package extension")),
"decision was {decision:?}",
);
}
/// A local file dependency injected via a packageExtension's
/// optionalDependencies does not bail when optionals are excluded from
/// the install — they aren't installed, so their contents can't be stale.
#[test]
fn returns_up_to_date_when_a_package_extension_optional_dependency_is_excluded() {
let (dir, config, manifest) = setup_fresh_install_with_config(
pacquet_config::NodeLinker::Isolated,
"root",
"1.0.0",
r#""dependencies":{"foo":"^1.0.0"}"#,
|config| {
config.package_extensions = Some(IndexMap::from([(
"foo@1".to_string(),
pacquet_config::PackageExtension {
optional_dependencies: Some(BTreeMap::from([(
"bar".to_string(),
"file:../bar".to_string(),
)])),
..Default::default()
},
)]));
},
);
let included = IncludedDependencies {
dependencies: true,
dev_dependencies: true,
optional_dependencies: false,
};
let settings = current_settings(config, pacquet_config::NodeLinker::Isolated, included);
let mut projects = BTreeMap::new();
projects.insert(
dir.path().to_string_lossy().into_owned(),
ProjectEntry { name: Some("root".into()), version: Some("1.0.0".into()) },
);
write_state(dir.path(), now_millis(), settings, projects);
let decision = check_optimistic_repeat_install(&OptimisticRepeatInstallCheck {
workspace_root: dir.path(),
config,
node_linker: pacquet_config::NodeLinker::Isolated,
included,
project_manifests: &[(dir.path().to_path_buf(), &manifest)],
is_workspace_install: false,
lockfile: MaybeLazyLockfile::Loaded(None),
catalogs: &BTreeMap::default(),
});
assert_eq!(decision, Decision::UpToDate);
}
/// An unparsable `pnpm.overrides` (here a `catalog:` reference with no
/// matching catalog entry) bails to the full install with the
/// parse-error reason, not the local-file reason: the cause is a
/// misconfiguration, and attributing it to a local file dependency
/// would mislead troubleshooting.
#[test]
fn returns_skipped_with_parse_error_reason_when_overrides_cannot_be_parsed() {
let (dir, config, manifest) = setup_fresh_install_with_config(
pacquet_config::NodeLinker::Isolated,
"root",
"1.0.0",
r#""dependencies":{"foo":"^1.0.0"}"#,
|config| {
config.overrides = Some(IndexMap::from([("bar".to_string(), "catalog:".to_string())]));
},
);
let decision = check(
dir.path(),
config,
pacquet_config::NodeLinker::Isolated,
&[(dir.path().to_path_buf(), &manifest)],
);
assert!(
matches!(decision, Decision::Skipped { reason } if reason.contains("cannot be parsed")),
"decision was {decision:?}",
);
}
/// A `catalog:` dependency whose catalog entry holds a bare local path
/// is a local file dependency after dereferencing — the catalog
/// resolver only bans the `workspace:`, `link:`, and `file:` protocols,
/// so the bare-path spelling reaches the local resolver. Same bail as
/// a direct local path.
#[test]
fn returns_skipped_when_a_catalog_dependency_resolves_to_a_local_path() {
let (dir, config, manifest) = setup_fresh_install(
pacquet_config::NodeLinker::Isolated,
"root",
"1.0.0",
r#""dependencies":{"foo":"catalog:"}"#,
);
let catalogs: Catalogs = BTreeMap::from([(
"default".to_string(),
BTreeMap::from([("foo".to_string(), "../foo".to_string())]),
)]);
let decision = check_optimistic_repeat_install(&OptimisticRepeatInstallCheck {
workspace_root: dir.path(),
config,
node_linker: pacquet_config::NodeLinker::Isolated,
included: isolated_included(),
project_manifests: &[(dir.path().to_path_buf(), &manifest)],
is_workspace_install: false,
lockfile: MaybeLazyLockfile::Loaded(None),
catalogs: &catalogs,
});
assert!(
matches!(decision, Decision::Skipped { reason } if reason.contains("local file dependency")),
"decision was {decision:?}",
);
}
/// A `catalog:` dependency resolving to a registry range keeps the
/// fast path: the dereferenced specifier is not a local path.
#[test]
fn returns_up_to_date_when_a_catalog_dependency_resolves_to_a_registry_range() {
let (dir, config, manifest) = setup_fresh_install(
pacquet_config::NodeLinker::Isolated,
"root",
"1.0.0",
r#""dependencies":{"foo":"catalog:"}"#,
);
let catalogs: Catalogs = BTreeMap::from([(
"default".to_string(),
BTreeMap::from([("foo".to_string(), "^1.0.0".to_string())]),
)]);
// Record the catalogs in the state so the catalog-cache comparison
// passes and the spec-deref path is what gets exercised, not the
// "catalogs cache outdated" bail.
let settings = current_settings_with_catalogs(
config,
pacquet_config::NodeLinker::Isolated,
isolated_included(),
&catalogs,
);
let mut projects = BTreeMap::new();
projects.insert(
dir.path().to_string_lossy().into_owned(),
ProjectEntry { name: Some("root".into()), version: Some("1.0.0".into()) },
);
write_state(dir.path(), now_millis(), settings, projects);
let decision = check_optimistic_repeat_install(&OptimisticRepeatInstallCheck {
workspace_root: dir.path(),
config,
node_linker: pacquet_config::NodeLinker::Isolated,
included: isolated_included(),
project_manifests: &[(dir.path().to_path_buf(), &manifest)],
is_workspace_install: false,
lockfile: MaybeLazyLockfile::Loaded(None),
catalogs: &catalogs,
});
assert_eq!(decision, Decision::UpToDate);
}
/// A `pnpm.overrides` entry spelled `catalog:` whose catalog entry
/// holds a local path bails like a direct local file override —
/// overrides are dereferenced through `parse_config_overrides` before
/// the check.
#[test]
fn returns_skipped_when_an_override_maps_through_a_catalog_to_a_local_path() {
let (dir, config, manifest) = setup_fresh_install_with_config(
pacquet_config::NodeLinker::Isolated,
"root",
"1.0.0",
r#""dependencies":{"foo":"^1.0.0"}"#,
|config| {
config.overrides = Some(IndexMap::from([("bar".to_string(), "catalog:".to_string())]));
},
);
let catalogs: Catalogs = BTreeMap::from([(
"default".to_string(),
BTreeMap::from([("bar".to_string(), "./vendor/bar".to_string())]),
)]);
let decision = check_optimistic_repeat_install(&OptimisticRepeatInstallCheck {
workspace_root: dir.path(),
config,
node_linker: pacquet_config::NodeLinker::Isolated,
included: isolated_included(),
project_manifests: &[(dir.path().to_path_buf(), &manifest)],
is_workspace_install: false,
lockfile: MaybeLazyLockfile::Loaded(None),
catalogs: &catalogs,
});
assert!(
matches!(decision, Decision::Skipped { reason } if reason.contains("override")),
"decision was {decision:?}",
);
}
/// Specs the git, remote-tarball, and registry resolvers claim must not
/// bail — matching them would disable the fast path for every project
/// with git dependencies.
#[test]
fn returns_up_to_date_when_specs_are_not_local_paths() {
let (dir, config, manifest) = setup_fresh_install(
pacquet_config::NodeLinker::Isolated,
"root",
"1.0.0",
concat!(
r#""dependencies":{"foo":"user/repo","bar":"github:user/repo","#,
// `quux` is a git shorthand whose committish ends in .tgz — it
// must not be mistaken for a local tarball.
r#""baz":"https://example.com/pkg.tgz","qux":"~1.2.3","quux":"user/repo#release.tgz"}"#,
),
);
let decision = check(
dir.path(),
config,
pacquet_config::NodeLinker::Isolated,
&[(dir.path().to_path_buf(), &manifest)],
);
assert_eq!(decision, Decision::UpToDate);
}
/// `link:` dependencies are symlinked — changes inside them flow
/// through without a reinstall, so they don't invalidate the fast path.
#[test]
fn returns_up_to_date_when_a_project_has_only_link_dependencies() {
let (dir, config, manifest) = setup_fresh_install(
pacquet_config::NodeLinker::Isolated,
"root",
"1.0.0",
r#""dependencies":{"foo":"link:../foo"}"#,
);
let decision = check(
dir.path(),
config,
pacquet_config::NodeLinker::Isolated,
&[(dir.path().to_path_buf(), &manifest)],
);
assert_eq!(decision, Decision::UpToDate);
}
/// `optimistic_repeat_install: false` opts the user out entirely.
#[test]
fn returns_skipped_when_config_disabled() {

6
pnpm-lock.yaml generated
View File

@@ -3833,6 +3833,12 @@ importers:
deps/status:
dependencies:
'@pnpm/catalogs.resolver':
specifier: workspace:*
version: link:../../catalogs/resolver
'@pnpm/catalogs.types':
specifier: workspace:*
version: link:../../catalogs/types
'@pnpm/config.parse-overrides':
specifier: workspace:*
version: link:../../config/parse-overrides