diff --git a/.changeset/plenty-jokes-wave.md b/.changeset/plenty-jokes-wave.md new file mode 100644 index 0000000000..ec48c8e31f --- /dev/null +++ b/.changeset/plenty-jokes-wave.md @@ -0,0 +1,6 @@ +--- +"@pnpm/installing.deps-resolver": patch +pnpm: patch +--- + +Improved handling of non-string version selectors in an internal function (e.g. `hoistPeers`). diff --git a/.changeset/wide-hotels-study.md b/.changeset/wide-hotels-study.md new file mode 100644 index 0000000000..b328b827fe --- /dev/null +++ b/.changeset/wide-hotels-study.md @@ -0,0 +1,5 @@ +--- +"@pnpm/core": patch +--- + +The `resolutionMode` option for `mutateModules` now defaults to `highest` to match the default in `@pnpm/config`. It previously defaulted to `lowest-direct`. diff --git a/.gitignore b/.gitignore index 965f4ac4d2..272f8beeea 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,8 @@ RELEASE.md .turbo .eslintcache .claude +.local-settings +.pnpm-typecheck.json ## custom modules-dir fixture __fixtures__/custom-modules-dir/**/fake_modules/ diff --git a/__utils__/scripts/src/typecheck-only.ts b/__utils__/scripts/src/typecheck-only.ts index 20015e229a..b5c19f55c1 100644 --- a/__utils__/scripts/src/typecheck-only.ts +++ b/__utils__/scripts/src/typecheck-only.ts @@ -1,5 +1,6 @@ import assert from 'node:assert/strict' import fs from 'node:fs' +import os from 'node:os' import path from 'node:path' import { findWorkspaceProjects } from '@pnpm/workspace.projects-reader' @@ -54,8 +55,14 @@ async function main (): Promise { JSON.stringify(typeCheckTSConfig, undefined, 2) ) - console.log('Running tsgo --build...') - execa('tsgo', ['--build', '--singleThreaded', typeCheckDir], { + const singleThreaded = resolveThreadingMode(repoRoot) + const args = ['--build'] + if (singleThreaded) { + args.push('--singleThreaded') + } + args.push(typeCheckDir) + console.log(`Running tsgo --build${singleThreaded ? ' --singleThreaded' : ''}...`) + execa('tsgo', args, { // The INIT_CWD variable is populated by package managers and points towards // the user's original working directory. It's more useful to run TypeScript // from the user's actual working directory so any type checking errors can @@ -72,6 +79,47 @@ async function main (): Promise { console.log('Running tsgo build done') } +const AUTO_SINGLE_THREAD_MEMORY_THRESHOLD_GB = 8 + +function resolveThreadingMode (repoRoot: string): boolean { + const { mode, source } = readThreadingMode(repoRoot) + switch (mode) { + case 'single-threaded': + return true + case 'multi-threaded': + return false + case 'auto': + return os.totalmem() / (1024 ** 3) < AUTO_SINGLE_THREAD_MEMORY_THRESHOLD_GB + default: + throw new Error( + `Invalid threading mode "${mode}" from ${source}. ` + + 'Valid values: auto, single-threaded, multi-threaded.' + ) + } +} + +function readThreadingMode (repoRoot: string): { mode: string, source: string } { + const envValue = process.env.PNPM_TYPECHECK_THREADING?.trim().toLowerCase() + if (envValue) { + return { mode: envValue, source: 'PNPM_TYPECHECK_THREADING env var' } + } + + for (const configPath of [ + path.join(repoRoot, '.local-settings', 'pnpm-typecheck.json'), + path.join(repoRoot, '.pnpm-typecheck.json'), + ]) { + if (fs.existsSync(configPath)) { + const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')) + const threading = typeof config.threading === 'string' ? config.threading.trim().toLowerCase() : '' + if (threading) { + return { mode: threading, source: configPath } + } + } + } + + return { mode: 'auto', source: 'default' } +} + main().catch((error: unknown) => { if (error && typeof error === 'object' && 'exitCode' in error && 'shortMessage' in error) { process.exit(error.exitCode as number) diff --git a/__utils__/scripts/src/worktree-new.ts b/__utils__/scripts/src/worktree-new.ts index 94939dd1f5..243cb6e5aa 100644 --- a/__utils__/scripts/src/worktree-new.ts +++ b/__utils__/scripts/src/worktree-new.ts @@ -69,15 +69,17 @@ if (/^\d+$/.test(arg)) { } } -// Symlink .claude into the new worktree, pointing at the bare repo's git common -// dir so all worktrees share the same Claude Code settings and approved commands. +// Symlink shared directories into the new worktree, pointing at the bare repo's +// git common dir so all worktrees share the same settings without duplication. const gitCommonDir = execSync('git rev-parse --git-common-dir', { encoding: 'utf8', cwd: repoRoot }).trim() -const sharedClaudeDir = path.resolve(repoRoot, gitCommonDir, '.claude') -const newClaudeDir = path.join(worktreePath, '.claude') -fs.mkdirSync(sharedClaudeDir, { recursive: true }) -if (!fs.existsSync(newClaudeDir)) { - // 'junction' works without elevated privileges on Windows; ignored on Unix - fs.symlinkSync(sharedClaudeDir, newClaudeDir, 'junction') +for (const dir of ['.claude', '.local-settings']) { + const sharedDir = path.resolve(repoRoot, gitCommonDir, dir) + const newDir = path.join(worktreePath, dir) + fs.mkdirSync(sharedDir, { recursive: true }) + if (!fs.existsSync(newDir)) { + // 'junction' works without elevated privileges on Windows; ignored on Unix + fs.symlinkSync(sharedDir, newDir, 'junction') + } } // Print path to stdout — allows: cd $(pnpm worktree:new ) diff --git a/installing/commands/src/installDeps.ts b/installing/commands/src/installDeps.ts index 313ec57062..3e78322bec 100644 --- a/installing/commands/src/installDeps.ts +++ b/installing/commands/src/installDeps.ts @@ -165,6 +165,12 @@ export async function installDeps ( ignoreFilteredInstallCache: true, }) if (upToDate) { + if (opts.hooks?.customResolvers?.some(r => r.shouldRefreshResolution)) { + logger.warn({ + message: 'shouldRefreshResolution hooks were skipped because optimisticRepeatInstall is enabled.', + prefix: opts.dir, + }) + } globalInfo('Already up to date') return } diff --git a/installing/deps-installer/src/install/extendInstallOptions.ts b/installing/deps-installer/src/install/extendInstallOptions.ts index c0a3770880..6dc2ede533 100644 --- a/installing/deps-installer/src/install/extendInstallOptions.ts +++ b/installing/deps-installer/src/install/extendInstallOptions.ts @@ -243,7 +243,7 @@ const defaults = (opts: InstallOptions): StrictInstallOptions => { pruneStore: false, rawConfig: {}, registries: DEFAULT_REGISTRIES, - resolutionMode: 'lowest-direct', + resolutionMode: 'highest', saveWorkspaceProtocol: 'rolling', scriptsPrependNodePath: false, shamefullyHoist: false, diff --git a/installing/deps-installer/test/catalogs.ts b/installing/deps-installer/test/catalogs.ts index 0f4e1a3143..7f401b7f53 100644 --- a/installing/deps-installer/test/catalogs.ts +++ b/installing/deps-installer/test/catalogs.ts @@ -178,13 +178,13 @@ test('lockfile contains catalog snapshots', async () => { { name: 'project1', dependencies: { - 'is-positive': 'catalog:', + '@pnpm.e2e/bar': 'catalog:', }, }, { name: 'project2', dependencies: { - 'is-negative': 'catalog:', + '@pnpm.e2e/foo': 'catalog:', }, }, ]) @@ -194,8 +194,8 @@ test('lockfile contains catalog snapshots', async () => { lockfileOnly: true, catalogs: { default: { - 'is-positive': '^1.0.0', - 'is-negative': '^1.0.0', + '@pnpm.e2e/bar': '^100.0.0', + '@pnpm.e2e/foo': '^100.0.0', }, }, }) @@ -203,8 +203,8 @@ test('lockfile contains catalog snapshots', async () => { const lockfile = readLockfile() expect(lockfile.catalogs).toStrictEqual({ default: { - 'is-positive': { specifier: '^1.0.0', version: '1.0.0' }, - 'is-negative': { specifier: '^1.0.0', version: '1.0.0' }, + '@pnpm.e2e/bar': { specifier: '^100.0.0', version: '100.1.0' }, + '@pnpm.e2e/foo': { specifier: '^100.0.0', version: '100.1.0' }, }, }) }) @@ -306,13 +306,13 @@ test('lockfile catalog snapshots retain existing entries on --filter', async () { name: 'project1', dependencies: { - 'is-negative': 'catalog:', + '@pnpm.e2e/bar': 'catalog:', }, }, { name: 'project2', dependencies: { - 'is-positive': 'catalog:', + '@pnpm.e2e/foo': 'catalog:', }, }, ]) @@ -322,16 +322,16 @@ test('lockfile catalog snapshots retain existing entries on --filter', async () lockfileOnly: true, catalogs: { default: { - 'is-positive': '^1.0.0', - 'is-negative': '^1.0.0', + '@pnpm.e2e/bar': '^100.0.0', + '@pnpm.e2e/foo': '^1.0.0', }, }, }) expect(readLockfile().catalogs).toStrictEqual({ default: { - 'is-negative': { specifier: '^1.0.0', version: '1.0.0' }, - 'is-positive': { specifier: '^1.0.0', version: '1.0.0' }, + '@pnpm.e2e/bar': { specifier: '^100.0.0', version: '100.1.0' }, + '@pnpm.e2e/foo': { specifier: '^1.0.0', version: '1.3.0' }, }, }) @@ -341,19 +341,19 @@ test('lockfile catalog snapshots retain existing entries on --filter', async () lockfileOnly: true, catalogs: { default: { - 'is-positive': '=3.1.0', - 'is-negative': '^1.0.0', + '@pnpm.e2e/bar': '^100.0.0', + '@pnpm.e2e/foo': '=100.0.0', }, }, }) expect(readLockfile().catalogs).toStrictEqual({ default: { - // The is-negative snapshot should be carried from the previous install, + // The @pnpm.e2e/bar snapshot should be carried from the previous install, // despite the current filtered install not using it. - 'is-negative': { specifier: '^1.0.0', version: '1.0.0' }, + '@pnpm.e2e/bar': { specifier: '^100.0.0', version: '100.1.0' }, - 'is-positive': { specifier: '=3.1.0', version: '3.1.0' }, + '@pnpm.e2e/foo': { specifier: '=100.0.0', version: '100.0.0' }, }, }) }) @@ -513,28 +513,28 @@ test('--fix-lockfile with --filter does not erase catalog snapshots', async () = { name: 'project1', dependencies: { - 'is-negative': 'catalog:', + '@pnpm.e2e/bar': 'catalog:', }, }, { name: 'project2', dependencies: { - 'is-positive': 'catalog:', + '@pnpm.e2e/foo': 'catalog:', }, }, ]) const catalogs = { default: { - 'is-positive': '^1.0.0', - 'is-negative': '^1.0.0', + '@pnpm.e2e/bar': '^100.0.0', + '@pnpm.e2e/foo': '^100.0.0', }, } const expectedCatalogsSnapshot: CatalogSnapshots = { default: { - 'is-negative': { specifier: '^1.0.0', version: '1.0.0' }, - 'is-positive': { specifier: '^1.0.0', version: '1.0.0' }, + '@pnpm.e2e/bar': { specifier: '^100.0.0', version: '100.1.0' }, + '@pnpm.e2e/foo': { specifier: '^100.0.0', version: '100.1.0' }, }, } @@ -1210,7 +1210,7 @@ describe('add', () => { ]) const catalogs = { - default: { '@pnpm.e2e/foo': '^100.0.0' }, + default: { '@pnpm.e2e/foo': '^100.1.0' }, } await mutateModules(installProjects(projects), { @@ -1238,12 +1238,12 @@ describe('add', () => { // Sanity check that the rest of the lockfile has expected contents. expect(readLockfile()).toMatchObject({ - catalogs: { default: { '@pnpm.e2e/foo': { specifier: '^100.0.0', version: '100.0.0' } } }, + catalogs: { default: { '@pnpm.e2e/foo': { specifier: '^100.1.0', version: '100.1.0' } } }, importers: { - project1: { dependencies: { '@pnpm.e2e/foo': { specifier: 'catalog:', version: '100.0.0' } } }, - project2: { dependencies: { '@pnpm.e2e/foo': { specifier: 'catalog:', version: '100.0.0' } } }, + project1: { dependencies: { '@pnpm.e2e/foo': { specifier: 'catalog:', version: '100.1.0' } } }, + project2: { dependencies: { '@pnpm.e2e/foo': { specifier: 'catalog:', version: '100.1.0' } } }, }, - packages: { '@pnpm.e2e/foo@100.0.0': expect.any(Object) }, + packages: { '@pnpm.e2e/foo@100.1.0': expect.any(Object) }, }) }) diff --git a/installing/deps-installer/test/install/fixLockfile.ts b/installing/deps-installer/test/install/fixLockfile.ts index 6806ad0b5b..4333d305cb 100644 --- a/installing/deps-installer/test/install/fixLockfile.ts +++ b/installing/deps-installer/test/install/fixLockfile.ts @@ -16,13 +16,13 @@ test('fix broken lockfile with --fix-lockfile', async () => { writeYamlFileSync(WANTED_LOCKFILE, { dependencies: { '@types/semver': { - specifier: '^5.3.31', + specifier: '5.3.31', version: '5.3.31', }, }, devDependencies: { fsevents: { - specifier: '^2.3.2', + specifier: '2.3.2', version: '2.3.2', }, }, @@ -44,10 +44,10 @@ test('fix broken lockfile with --fix-lockfile', async () => { await install({ dependencies: { - '@types/semver': '^5.3.31', + '@types/semver': '5.3.31', }, devDependencies: { - 'core-js-pure': '^3.16.2', + 'core-js-pure': '3.16.2', }, }, testDefaults({ fixLockfile: true })) diff --git a/installing/deps-installer/test/install/injectLocalPackages.ts b/installing/deps-installer/test/install/injectLocalPackages.ts index b22ee38b07..23ba71be2d 100644 --- a/installing/deps-installer/test/install/injectLocalPackages.ts +++ b/installing/deps-installer/test/install/injectLocalPackages.ts @@ -1802,7 +1802,7 @@ test('injected local packages are deduped', async () => { 'project-1': 'workspace:1.0.0', }, devDependencies: { - 'is-positive': '1.0.0', + 'is-positive': '2.0.0', }, dependenciesMeta: { 'project-1': { @@ -1954,9 +1954,9 @@ test('injected local packages are deduped', async () => { }, }) expect(lockfile.packages['project-1@file:project-1(is-positive@1.0.0)']).toBeFalsy() - expect(lockfile.snapshots['project-2@file:project-2(is-positive@2.0.0)']).toEqual({ + expect(lockfile.snapshots['project-2@file:project-2(is-positive@1.0.0)']).toEqual({ dependencies: { - 'project-1': 'file:project-1(is-positive@2.0.0)', + 'project-1': 'file:project-1(is-positive@1.0.0)', }, transitivePeerDependencies: ['is-positive'], }) diff --git a/installing/deps-installer/test/lockfile.ts b/installing/deps-installer/test/lockfile.ts index 14390e49e1..132767d362 100644 --- a/installing/deps-installer/test/lockfile.ts +++ b/installing/deps-installer/test/lockfile.ts @@ -1244,6 +1244,7 @@ packages: test('a lockfile v6 with merge conflicts is autofixed', async () => { const project = prepareEmpty() + await addDistTag({ package: '@pnpm.e2e/dep-of-pkg-with-1-dep', version: '100.1.0', distTag: 'latest' }) fs.writeFileSync(WANTED_LOCKFILE, `\ lockfileVersion: '${LOCKFILE_VERSION}' diff --git a/installing/deps-resolver/src/hoistPeers.ts b/installing/deps-resolver/src/hoistPeers.ts index 8b56cbced8..421fa7d278 100644 --- a/installing/deps-resolver/src/hoistPeers.ts +++ b/installing/deps-resolver/src/hoistPeers.ts @@ -29,7 +29,8 @@ export function hoistPeers ( if (opts.allPreferredVersions![peerName]) { const versions: string[] = [] const nonVersions: string[] = [] - for (const [spec, specType] of Object.entries(opts.allPreferredVersions![peerName])) { + for (const [spec, selector] of Object.entries(opts.allPreferredVersions![peerName])) { + const specType = typeof selector === 'string' ? selector : selector.selectorType if (specType === 'version') { versions.push(spec) } else { diff --git a/installing/deps-resolver/test/hoistPeers.test.ts b/installing/deps-resolver/test/hoistPeers.test.ts index a268e5b19c..8a183b6578 100644 --- a/installing/deps-resolver/test/hoistPeers.test.ts +++ b/installing/deps-resolver/test/hoistPeers.test.ts @@ -96,6 +96,21 @@ test('hoistPeers handles workspace: protocol range without throwing', () => { }) }) +// Regression test for https://github.com/pnpm/pnpm/pull/11048 +test('hoistPeers handles version selector with weight', () => { + expect(hoistPeers({ + autoInstallPeers: true, + allPreferredVersions: { + foo: { + '1.0.0': { selectorType: 'version', weight: 1 }, + }, + }, + workspaceRootDeps: [], + }, [['foo', { range: '1' }]])).toStrictEqual({ + foo: '1.0.0', + }) +}) + test('getHoistableOptionalPeers only picks a version that satisfies all optional ranges', () => { expect(getHoistableOptionalPeers({ foo: ['2', '2.1'], diff --git a/releasing/commands/test/deploy/shared-lockfile.test.ts b/releasing/commands/test/deploy/shared-lockfile.test.ts index 450954cc73..ab4af31334 100644 --- a/releasing/commands/test/deploy/shared-lockfile.test.ts +++ b/releasing/commands/test/deploy/shared-lockfile.test.ts @@ -899,7 +899,7 @@ test('deploy with a shared lockfile that has peer dependencies suffix in workspa 'project-2': 'workspace:*', }, peerDependencies: { - 'is-negative': '>=1.0.0', + 'is-negative': '1.0.0', 'project-2': '*', }, }, @@ -907,7 +907,7 @@ test('deploy with a shared lockfile that has peer dependencies suffix in workspa name: 'project-2', version: '0.0.0', peerDependencies: { - 'is-positive': '>=1.0.0', + 'is-positive': '1.0.0', }, }, }