package.json как договор: type, exports, диапазоны, lock
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
В статье про npm package.json был запиской для npm: имя проекта, скрипты, список зависимостей. Но в него заглядывает и Node — при каждом import и require. Поле type решает, чем считать файл .js, поле exports — какой файл пакета получит тот, кто его подключает. А диапазоны версий вместе с lock-файлом решают, какой код вообще окажется в node_modules на сервере. Сегодня соберём модели этих решений: поиск type, резолвер условий exports, проверку диапазона и установку по lock-файлу. Сверим их с node и pnpm и разберём, почему установка в CI отказывается работать, когда package.json поправили, а lock-файл — нет.
В прошлой статье всё решало расширение: .cjs — CommonJS, .mjs — ES-модуль при любых настройках проекта. Про обычный .js Node спрашивает package.json, причём не корневой, а ближайший: идёт от папки файла вверх и останавливается на первом найденном. "type": "module" в нём делает все .js ниже ES-модулями, "type": "commonjs" — модулями CommonJS. Главное здесь — остановка: package.json без поля type тоже прекращает поиск, и type из папки выше до файла уже не доходит.
На этом правиле держатся пакеты, которые везут две сборки сразу. У nodemailer 10.0.10 в корневом package.json стоит "type": "module", а в папке dist/cjs лежит свой package.json с единственным полем — { "type": "commonjs" }. Файлы в этой папке написаны через exports.x = …, и без такого файла не загрузились бы. Проверим на модели. Проект notes-api объявил "type": "module", в его node_modules лежат nodemailer и debug 4.4.3; у второго в package.json поля type нет вовсе. Прогноз: чем модель сочтёт сборку dist/cjs и главный файл debug, который лежит внутри проекта с "type": "module"?
Сборка dist/cjs стала CommonJS по своему package.json, хотя в корне пакета записано "type": "module": до корня поиск не дошёл. У debug поиск остановился на собственном package.json пакета. Поля type в нём нет, но это ближайший файл, и "type": "module" проекта на пакет не распространяется. Так и должно быть: пакет из node_modules написан под свой package.json, а не под настройки проекта, в который его поставили. Раз type не объявлен, решает сам текст файла: ни import, ни export в debug нет — значит, CommonJS.
Объявленный type — обещание, а не догадка. Файл с export под "type": "commonjs" Node заново не разбирает, а падает с SyntaxError: Unexpected token 'export'.
В модели определение по тексту упрощено до поиска строк, которые начинаются с import или export. Настоящий Node пробует разобрать файл как CommonJS и, если этому мешает синтаксис ES-модуля, разбирает его заново как ES-модуль. Проверим на своём проекте: package.json без type, в server.js — export. Прогноз: выполнится ли файл и скажет ли Node что-нибудь ещё?
{ "name": "notes-api", "private": true }
(node:<pid>) [MODULE_TYPELESS_PACKAGE_JSON] Warning: Module type of file://<пример>/server.js is not specified and it doesn't parse as CommonJS. Reparsing as ES module because module syntax was detected. This incurs a performance overhead. To eliminate this warning, add "type": "module" to <пример>/package.json. (Use `node --trace-warnings ...` to show where the warning was created) слушаю порт 3000
Файл выполнился — слушаю порт 3000 стоит последней строкой. Но сначала Node предупредил: тип модуля не указан, разобрать файл как CommonJS не вышло, и его разобрали второй раз, уже как ES-модуль, — а это стоит времени. Подсказка называет и лечение — дописать "type": "module" в package.json. Предупреждение появляется, когда package.json есть, а type в нём нет; без package.json вовсе Node молча выполняет такой файл как ES-модуль. (node:<pid>) и <пример> — метки записи: номер процесса и полный путь у каждого свои.
Отсюда правило для своего проекта: type пишут всегда, даже когда всё и так работает. Дело не только в двойном разборе. Файл без import и export сегодня CommonJS, но стоит дописать в него строку с import, и он станет ES-модулем, а module.exports ниже упадёт с ReferenceError: module is not defined in ES module scope. С объявленным type вид файла не зависит от того, что в нём написано.
Вход в пакет в первой задаче статьи про require задавало поле main — одна строка, один файл для всех. У современного пакета рядом есть поле exports, и если оно есть, main Node не читает вовсе. exports делает две работы. Во-первых, это список дверей: подключить можно только перечисленные пути, а всё остальное закрыто, даже существующий файл. Во-вторых, у каждой двери бывают условия — объект, где ключ называет случай, а значение — файл для этого случая.
Случаи Node знает свои. Для import это набор node, node-addons, module-sync и import, для require — те же, но с require вместо import. Ключ default подходит всегда. Важно, как набор применяется: Node идёт по ключам объекта в том порядке, в каком они записаны в package.json, и берёт первый ключ из своего набора. Не самый точный и не самый специальный, а первый. Порядок внутри набора ничего не значит — решает порядок ключей у пакета.
Резолвер ниже разбирает настоящий exports пакета zod 4.6.5 — два входа из двенадцати. Первым ключом у каждого стоит @zod/source. Такого условия Node не знает — это своё условие zod, и Node его пропускает. Инструмент, которому это условие назвали (у node для этого есть флаг --conditions), получит исходник на TypeScript. Второй пакет, notes-kit, отдаёт две сборки, но ключ default у него записан первым. Прогноз: какой файл достанется import у notes-kit и что изменит @zod/source, дописанное в конец набора условий?
import и require получили у zod разные файлы, а types не сработал ни разу: такого условия в наборе Node нет. Вторым он стоит не случайно. TypeScript читает тот же exports со своим набором, где types есть, и тоже берёт первый подходящий ключ — окажись types после import, компилятор до него не дошёл бы. @zod/source победил, хотя в наборе стоял последним: у пакета этот ключ первый. Путь ./index.cjs в exports не перечислен, и дверь закрыта, хотя файл на месте.
А notes-kit отдал import CommonJS-сборку: default подходит всем и стоит первым, до ключа import очередь не дошла. Node такую запись принимает молча, и заметить ошибку можно только по тому, что ES-сборку не загружает никто. Поэтому default всегда ставят последним.
Теперь настоящий Node. Папку node_modules в записанный пример не положить, поэтому пакет подключает сам себя: если в package.json есть name и exports, файлы внутри проекта могут звать его по имени. main в примере указывает на ES-сборку, условие import — тоже на неё, require — на CommonJS-сборку. main.cjs подключает пакет обоими способами, пробует закрытый путь и сравнивает функции createNote из двух сборок. Прогноз: что получит require, чем кончится подключение notes-kit/src/index.cjs и совпадут ли функции?
{ "name": "notes-kit", "type": "module", "main": "./src/index.js", "exports": { ".": { "import": "./src/index.js", "require": "./src/index.cjs" } } }
require: CommonJS-сборка, src/index.cjs notes-kit/src/index.cjs: ERR_PACKAGE_PATH_NOT_EXPORTED import: ESM-сборка, src/index.js одна и та же createNote: false
require получил CommonJS-сборку, хотя main указывает на ES-сборку: при поле exports Node на main не смотрит. Закрытый путь дал ERR_PACKAGE_PATH_NOT_EXPORTED, хотя файл src/index.cjs существует и строкой выше уже загружен через require("notes-kit"). Строка про import пришла позже: import() возвращает промис, и его then выполнился после синхронного кода.
Последняя строка — ловушка двух сборок. В одном процессе оказались два модуля с одним смыслом, и createNote в них — разные функции. С функциями это не страшно, с состоянием — страшно: кеш, счётчик, класс ошибки для instanceof у каждой сборки свои. Библиотека, которую ваш код подключает через import, а соседний пакет — через require, живёт в процессе дважды.
Пакеты обходят это тремя способами. pg 8.23.0 держит одну настоящую сборку на CommonJS, а файл для import у него — тонкая обёртка: import pg from '../lib/index.js' и повторный экспорт полей. @nestjs/common 12 публикует только ES-модули: "type": "module", вход "." в exports — строка "./index.js", и проект на CommonJS подключает его через require ES-модуля. Третий способ — условие module-sync. Node 26 проверяет его и для import, и для require, поэтому ES-сборка под этим ключом достаётся обоим, и копия одна. Одна оговорка: в такой сборке не должно быть await верхнего уровня — почему, объясняет следующий раздел.
Долгие годы require ES-модули не загружал, и пакет, ушедший на ESM, проекту на CommonJS доставался только через асинхронный import(). В Node 26 require загружает ES-модуль без флагов и предупреждений, и на этом держится переход Nest 12 на ESM. Условий два, и оба следуют из того, что вы уже знаете. require синхронный: он возвращает результат сразу, ждать ему некогда. Значит, в подключаемом графе ES-модулей не должно быть await на верхнем уровне — такой модуль не выполнить до конца, не отдав управление. И в графе не должно быть круга через require: недописанный exports CommonJS и строгий порядок выполнения ESM друг с другом не уживаются.
В примере main.cjs подключает три ES-модуля. format.mjs — обычный, с экспортом по умолчанию. config.mjs читает settings.json через await прямо на верхнем уровне. report.mjs сам импортирует main.cjs, который его подключает. Прогноз: какие ключи окажутся у объекта из format.mjs, какие из подключений упадут и где встанет строка с портом?
const format = require("./format.mjs"); console.log("format.mjs:", Object.keys(format).join(", ")); console.log(format.default("купить хлеб")); try { require("./config.mjs"); } catch (err) { console.log("config.mjs:", err.code); console.log(err.message.split("\n")[0]); } import("./config.mjs").then((config) => console.log("import() config.mjs: порт", config.port)); try { require("./report.mjs"); } catch (err) { console.log("report.mjs:", err.code); } module.exports = { name: "notes-api" };
format.mjs: __esModule, default, prefix заметка: купить хлеб config.mjs: ERR_REQUIRE_ASYNC_MODULE require() cannot be used on an ESM graph with top-level await. Use import() instead. To see where the top-level await comes from, use --experimental-print-required-tla. report.mjs: ERR_REQUIRE_CYCLE_MODULE import() config.mjs: порт 3000
require вернул объект пространства имён — такой же, какой дал бы import * as. Экспорт по умолчанию лежит в поле default, а не отдаётся целиком, как module.exports. Лишний ключ __esModule Node добавил, потому что у модуля есть экспорт по умолчанию, и ради этого ключа отдал обёртку над пространством имён: import() того же файла вернёт объект без него. По этой пометке код, который TypeScript или Babel собрали в CommonJS, понимает, что default надо брать из поля. Модуль без экспорта по умолчанию пометки не получает.
config.mjs упал с ERR_REQUIRE_ASYNC_MODULE, и текст ошибки сразу называет выход — import(). Он возвращает промис и потому может дождаться await внутри модуля: строка с портом пришла последней, после всего синхронного кода. report.mjs дал ERR_REQUIRE_CYCLE_MODULE. main.cjs ещё выполняется и свой module.exports не дописал, а ES-модулю нужен законченный модуль. Та самая половина, которую в статье про круг CommonJS отдавал без ошибки, здесь превращается в ошибку при загрузке.
require(esm) не обходит exports. Пакет, у которого в условиях есть только import, для require закрыт: Node ответит ERR_PACKAGE_PATH_NOT_EXPORTED раньше, чем дойдёт до загрузки модуля.
Как читаются ^ и ~, показала таблица в статье про npm: крышка разрешает всё до следующего мажора, тильда — до следующего минора. Две вещи в таблицу не поместились, а на сервере встречаются постоянно.
Первая — версии с нулём впереди. По соглашению semver 0.y.z — начальная разработка, и ломать в ней можно что угодно. Поэтому крышка не пускает дальше первой ненулевой цифры: ^0.2.3 — от 0.2.3 до 0.3.0, а ^0.0.3 — от 0.0.3 до 0.0.4, то есть одна версия. Пример рядом: у drizzle-orm под тегом latest на 29 сентября 2026 года стоит 0.45.3, а 1.0 выходит кандидатами. ^0.45.3 не притянет 0.46.0 — для пакета на нуле смена минора и есть смена мажора.
Вторая — предварительные версии: 8.0.0-rc.15, 1.0.0-rc.4, 3.0.0-canary.1. Дефис с суффиксом значит «ещё не выпуск», и такая версия младше выпуска с теми же тремя числами: 8.0.0-rc.15 идёт до 8.0.0. Суффикс сравнивается по частям между точками: числа — как числа, слова — по алфавиту, число младше слова. Главное правило: диапазон без дефиса предварительную версию не пропускает — ни ^7.10.0, ни >=7.0.0. Поэтому кандидат, который стоял у prisma на теге latest, в проект с диапазоном ^7.10.0 не приедет — только к тому, кто поставил пакет без версии. Тем же путём он придёт и по записи *: для неё npm и pnpm берут версию под тегом latest, не спрашивая semver. Если же диапазон сам назван предварительной версией, как ^8.0.0-rc.15, он пропустит следующие кандидаты того же выпуска и сам выпуск, но не предварительные версии других выпусков.
Соберём проверку. parse раскладывает версию на три числа и части суффикса, compare сравнивает две версии, satisfies строит для ^ и ~ верхнюю границу и отдельно проверяет суффикс. Прогноз: какие из семи проверок дадут true?
Верхние границы вышли разные: у ^0.2.3 это 0.3.0, у ^0.0.3 — 0.0.4, поэтому 0.0.4 уже мимо. 8.0.0-rc.15 в ^7.10.0 не прошёл, и здесь пригодилась вторая проверка: границу 8.0.0 он формально не пересекает, ведь предварительная версия младше выпуска. Без отдельного запрета на суффикс кандидат восьмой версии пролез бы в диапазон седьмой. ^8.0.0-rc.15 пропустил rc.16 и выпуск 8.0.0, а 8.1.0-beta.1 — нет: это предварительная версия другого выпуска. В сортировке rc.2 встал раньше rc.10 — число сравнилось числом, а строкой было бы наоборот.
Результаты совпадают с пакетом semver, которым пользуется сам npm: модель сверена с semver 7.8.4 из npm 11.17.0 на всех парах из двух с лишним сотен версий с суффиксами и без, для ^, ~ и точной версии.
Диапазон в package.json — пожелание: «подойдёт любая 4.x не младше 4.3.4». Какую версию поставить, решает установщик, и решение зависит от дня: сегодня самая свежая подходящая одна, через неделю другая. Lock-файл записывает решение: точную версию каждого пакета, включая зависимости зависимостей, хеш архива и — это понадобится дальше — диапазон, из которого решение принято. Установка по lock-файлу не решает заново, а повторяет записанное.
Цену разницы показывает настоящая история. 8 сентября 2025 года после взлома учётной записи сопровождающего в реестре появилась debug 4.4.2 с вредоносным кодом, и в тот же день её сняли — как версии axios, которых нет в списке. Диапазон ^4.3.4 она проходила. Модель ниже ставит debug через полчаса после её выпуска: по lock-файлу, записанному в июне; без lock-файла; без lock-файла, но с запретом на версии моложе суток. Последняя попытка — CI, где диапазон в package.json подняли, а lock-файл не обновили. Прогноз: какие версии получатся в первых трёх строках и что ответит CI в четвёртой?
По lock-файлу CI получил 4.4.1: решение принято в июне, и выход новой версии его не касается. Без lock-файла установщик честно взял самую свежую подходящую — 4.4.2. Запрет на версии моложе суток вернул 4.4.1, и в pnpm 11 такой запрет включён по умолчанию: настройка minimumReleaseAge равна 1440 минутам. Версия, которую сняли в день выхода, до такой установки просто не доживает.
А правка диапазона без переустановки обернулась ошибкой, и это не придирка. Lock-файл помнит, что 4.4.1 выбрана из ^4.3.4. Для ^4.4.0 выбора никто не делал, и сделать его молча в CI значило бы поставить на сервер то, что ни разу не ставилось у разработчика.
Теперь настоящий pnpm — менеджер, на котором собран монорепо учебника. В папке только package.json с двумя зависимостями. Флаг --offline велит брать пакеты из хранилища на диске и не ходить в сеть: так запись не зависит от того, что реестр отдаст в день съёмки. Вкладка pnpm-lock.yaml — файл, который команда записала. Прогноз: сколько пакетов добавит pnpm, если зависимостей в package.json две?
{ "name": "notes-api", "private": true, "dependencies": { "dayjs": "^1.11.10", "debug": "^4.3.4" } }
Progress: resolved 1, reused 0, downloaded 0, added 0 Packages: +3 +++ Progress: resolved 3, reused 3, downloaded 0, added 3, done dependencies: + dayjs 1.11.21 + debug 4.4.3 Done in <время> using pnpm v11.17.0
Пакетов три: debug тянет ms, которого в package.json нет. reused 3, downloaded 0 — все три нашлись в общем хранилище pnpm. В lock-файле три раздела, у каждого своя работа. importers — решения для package.json: у каждой зависимости записан specifier, диапазон как он написан, и выбранная version. packages — что ставить: у каждой версии integrity, хеш sha512 её архива. Отдай реестр под тем же номером другой архив, хеш не сойдётся, и установка остановится. snapshots — граф: debug@4.4.3 получает ms ровно 2.1.3, хотя в package.json самого debug записан диапазон ^2.1.3. Lock-файл фиксирует и то, чего вы сами не выбирали.
Повторим четвёртую строку модели на настоящем pnpm. Lock-файл тот же, а в package.json диапазон debug поднят до ^4.4.0. Записанная 4.4.3 под новый диапазон подходит. Флаг --frozen-lockfile запрещает pnpm менять lock-файл — так установка идёт в CI. Прогноз: поставит ли pnpm пакеты, раз подходящая версия уже записана?
{ "name": "notes-api", "private": true, "dependencies": { "dayjs": "^1.11.10", "debug": "^4.4.0" } }
✓ Lockfile passes supply-chain policies (verified <когда> ago) [ERR_PNPM_OUTDATED_LOCKFILE] Cannot install with "frozen-lockfile" because pnpm-lock.yaml is not up to date with <ROOT>/package.json Note that in CI environments this setting is true by default. If you still need to run install in such cases, use "pnpm install --no-frozen-lockfile" Failure reason: specifiers in the lockfile don't match specifiers in package.json: * 1 dependencies are mismatched: - debug (lockfile: ^4.3.4, manifest: ^4.4.0)
Не поставил и вышел с кодом 1. pnpm сравнивает строки: в lock-файле ^4.3.4, в package.json ^4.4.0, значит, решение принималось для другого пожелания, и неважно, что 4.4.3 подходит под оба. У npm проверка другая: npm ci в npm 11.17.0 смотрит, подходит ли записанная версия под новый диапазон. Эту правку он пропустил бы, а ~4.3.4 — уже нет: 4.4.3 под тильду не подходит, и npm ci останавливается с ошибкой, что package.json и package-lock.json не согласованы. Итог у обоих один: в CI lock-файл не переписывается. Подсказка в выводе говорит и то, что в CI --frozen-lockfile включён сам, без флага. Первая строка с галочкой — проверка lock-файла по правилам pnpm 11, тому самому запрету на слишком свежие версии; вердикт взят из кеша, отсюда verified … ago.
Отсюда рабочее правило: диапазон меняют только вместе с lock-файлом — pnpm install у себя, оба файла одним коммитом, и lock-файл приложения всегда лежит в git. Почему в Dockerfile их копируют раньше исходников, разбирает статья «Образ из монорепо: слои как кеш». А возраст версий, пометки deprecated и предварительные версии на latest по package.json и lock-файлу будет проверять скрипт из статьи «Свой аудитор зависимостей».
Хеш гарантирует, что архив тот же, но не то, что пакет сделает при установке. У пакета бывают скрипты install и postinstall — команды, которые менеджер выполняет сразу после распаковки. Так esbuild проверяет, на месте ли бинарник под вашу систему, и докачивает его, если нет. Тем же путём в проект попадает и чужой код, поэтому pnpm не запускает скрипты зависимостей, пока их не разрешили явно. В pnpm 11 разрешения лежат в pnpm-workspace.yaml, в поле allowBuilds. Так оно выглядит в монорепо учебника:
true — выполнить скрипт, false — не выполнять и не напоминать. Пакет со скриптом, которого в списке нет, pnpm 11.17.0 устанавливает, но скрипт пропускает, печатает ERR_PNPM_IGNORED_BUILDS и завершается кодом 1 — проверено на esbuild 0.28.2. В CI такая установка упадёт, а на ноутбуке ошибку легко пропустить под длинным выводом. minimumReleaseAgeExclude — исключения из запрета на версии моложе суток: pnpm в режиме по умолчанию дописывает сюда строку сам, когда установке понадобилась версия младше суток.
- Поле
typeNode ищет в ближайшемpackage.jsonвверх от файла и останавливается на первом найденном, даже если поля в нём нет..mjsи.cjsрешают сами, а пакет вnode_modulesживёт по своемуpackage.json, не по проекту. - Без
typeNode 26 определяет вид.jsпо тексту: сimportилиexport— ES-модуль, но с двойным разбором и предупреждением. Своему проектуtypeпишут всегда. exportsзакрывает всё, что не перечислено, и отменяетmain. Среди условий Node, как и TypeScript со своим набором, берёт первый подходящий ключ в порядке записи, поэтомуtypesставят раньшеimportиrequire, аdefault— последним. Из шаблонов со звёздочкой побеждает самый точный.- Две сборки для
importиrequireдают в процессе две копии состояния. Лечат обёрткой над одной сборкой, какpg, публикацией только ESM, как Nest 12, или условиемmodule-sync.requireES-модуля в Node 26 работает без флагов, пока в графе нетawaitверхнего уровня и круга черезrequire. - Крышка держит первую ненулевую цифру:
^0.2.3— до 0.3.0,^0.0.3— одна версия. Предварительная версия младше выпуска, её суффикс сравнивается по частям, и диапазон без дефиса такие версии не пропускает. - Lock-файл записывает решение: точные версии всего дерева, хеши архивов и диапазон, из которого решение принято. В CI он не переписывается: pnpm отказывает при любой правке диапазона,
npm ci— когда записанная версия под новый диапазон не подходит. Диапазон меняют вместе с lock-файлом, а скрипты установки зависимостей pnpm 11 выполняет только изallowBuilds.
1/4В package.json проекта "type": "module". В node_modules/debug свой package.json без поля type, а в главном файле пакета — module.exports = … и ни одного import. Как Node загрузит этот файл?
2/4У пакета "exports": { "default": "./index.cjs", "import": "./index.mjs" }. Что получит import в Node?
3/4В package.json записано "image-kit": "^0.34.0". В реестре есть 0.34.5, 0.35.4 и 1.0.0-rc.1, lock-файла нет. Что поставит установщик?
4/4В CI упал pnpm install: specifiers in the lockfile don't match specifiers in package.json. В lock-файле debug 4.4.3 из ^4.3.4, в package.json теперь ^4.4.0. Как починить?
Звёздочка в exports
У pg 8.23.0 в exports, кроме входа ".", есть шаблоны "./lib/*": "./lib/*.js" и "./lib/*.js": "./lib/*.js". Звёздочка в ключе совпадает с любым куском пути, и этот кусок встаёт на место звёздочки в значении: pg/lib/client ведёт в ./lib/client.js. Резолвер из статьи понимает только точные ключи. Научите его шаблонам по правилу Node: если под путь подходят несколько шаблонов, берётся тот, у которого длиннее часть до звёздочки, а при равенстве — более длинный ключ. Порядок записи шаблонов, в отличие от условий, ничего не решает. ./esm/index.mjs должен остаться закрытым.
Сначала точный ключ, потом шаблоны, из подходящих — самый точный:
Вывод:
Под ./lib/client.js подходят оба шаблона. Часть до звёздочки у них одинаковая, ./lib/, и решает длина ключа. Бери резолвер первый подходящий по порядку записи, ./lib/* отдал бы ./lib/client.js.js: у pg короткий шаблон записан первым. Настоящий Node разрешает эти пути так же — проверено на pg 8.23.0: pg/lib/client и pg/lib/client.js ведут в один файл, а pg/esm/index.mjs даёт ERR_PACKAGE_PATH_NOT_EXPORTED, хотя это файл того же пакета для import.
Проверка subpath.length >= k.length тоже взята из Node: она не даёт ключу ./lib/*.js совпасть с путём ./lib/.js, где на звёздочку не осталось ни символа.
Что не сошлось с lock-файлом
Когда --frozen-lockfile отказывает, pnpm перечисляет причины тремя списками: зависимости, которые в package.json добавили, которые из него убрали, и те, чей диапазон разошёлся с записанным. Сверяет он раздел importers. Ниже этот раздел из прогона, а package.json поправили сразу в трёх местах. Допишите outdated, чтобы она возвращала три списка в том виде, как их печатает пример. ms в lock-файле уже есть как зависимость debug, но в importers его нет — считайте его добавленным, как это делает pnpm.
Два прохода: по package.json — новые и расходящиеся, по lock-файлу — убранные:
Вывод:
На этой же правке настоящий pnpm 11.17.0 печатает те же три пункта своими словами: 1 dependencies were added: ms@^2.1.3, 1 dependencies were removed: dayjs@^1.11.10 и debug (lockfile: ^4.3.4, manifest: ^4.4.0). Сравнение строк, а не версий, делает проверку дешёвой: ей не нужны ни реестр, ни разбор диапазонов, и отказ приходит до всякой установки.
Какую версию выберет установщик
Без lock-файла установщик берёт из реестра самую старшую версию, которая подходит под диапазон. pickVersion пока возвращает последнюю версию списка. Перепишите её на satisfies и compare из статьи; если подходящей версии нет, пусть возвращает null. Список — настоящие версии пакета ms, среди них бета, canary и nightly.
Отфильтровать подходящие, упорядочить, взять старшую:
Вывод:
Выпуск 2.1.3 при ^2.0.0 и 2.0.0 при ~2.0.0 — обычные крышка и тильда. ^0.5.0 остался на 0.5.x. ^3.0.0-beta.0 назван предварительной версией, поэтому пропустил и canary того же выпуска 3.0.0. Старшей оказалась canary.202508261828: canary обогнал beta по алфавиту, а число в суффиксе сравнилось числом и обогнало canary.1. ^4.0.0 не нашёл ничего — у 4.0.0 есть только nightly, выпуска нет. semver.maxSatisfying из npm 11.17.0 на этом списке отвечает так же.
Настоящий npm перед таким выбором смотрит ещё на тег latest: если версия под ним подходит под диапазон, npm берёт её, даже когда в реестре есть подходящая версия старше.