Свой аудитор зависимостей: реестр, загрузки, возраст — и что из этого врёт
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Шесть вопросов о чужом пакете разложены в части I, и на первые три там отвечает функция по датированному снимку реестра. Снимок пришлось вписать в код руками: в браузере сети нет. Теперь у вас есть Node — и те же вопросы можно задать реестру самому, а ответы сложить не про один пакет, а про все зависимости сервера разом. Соберём такой аудитор: он читает package.json и pnpm-lock.yaml, ходит на два адреса npm и печатает вердикт по каждой строке. А потом разберём, что из напечатанного правда, что правда наполовину и почему четыре вопроса из шести машине отдать нельзя.
Первый адрес — registry.npmjs.org/имя. Он отдаёт packument: весь пакет одним JSON — теги, все версии, которые можно поставить, время каждой публикации (в том числе снятых версий), список сопровождающих. Загрузок в нём нет: за ними второй адрес, api.npmjs.org/downloads/point/начало:конец/имя, и это отдельная служба со своими правилами. Больше аудитору спрашивать негде: всё, что он скажет, он возьмёт из восьми полей.
| поле | адрес | на какой вопрос отвечает |
|---|---|---|
dist-tags.latest | packument | какая версия приедет по npm i имя |
time[latest] | packument | сколько месяцев пакет молчит |
time.modified | packument | ни на какой — см. ниже |
versions[latest].deprecated | packument | снят ли пакет самим автором |
maintainers | packument | сколько людей могут выпустить версию |
versions[latest].peerDependencies | packument | с какими мажорами соседей пакет живёт |
downloads за неделю | api.npmjs.org | доля в нише |
downloads за ту же неделю год назад | api.npmjs.org | рост против медианы реестра |
Строка time.modified выглядит как самая удобная: одно число, и сразу видно, когда пакет трогали. Она и есть главная ловушка. Дату выпуска в реестре держит time[версия] — отдельная метка на каждую опубликованную версию, — а modified двигают любые изменения записи о пакете, в том числе те, после которых новой версии не появилось вовсе.
Ниже — снимок шести пакетов, снятый 29.09.2026: обе даты и загрузки за одну и ту же неделю сентября 2026 и 2025 года. Живых запросов в коде нет, сети в песочнице тоже. Считает вердикт функция тем же способом, что health() из части I: месяцы календарно, вопросы по порядку, первый «нет» отвечает за весь пакет. Прогноз: у каких пакетов две даты дают разные ответы и насколько разные?
Расходятся две даты у трёх пакетов из шести, но дальше всего — у ts-node и rxjs, и не на проценты. У ts-node версия 10.9.2 вышла 08.12.2023, и в time после неё нет ни одной записи — а modified стоит на 13.10.2025. По modified пакет выглядит правленным год назад, по дате выпуска молчит 33 месяца. У rxjs расхождение то же, а причина другая: 4 августа 2026 года вышла 9.0.0-beta.0, и modified уехал за ней, а latest остался на 7.8.2 от февраля 2025-го. Выходит 1 месяц против 19.
Вопрос 2 при этом обоих пропускает. Загрузки ts-node за год выросли в 1,70 раза, rxjs — в 1,97; порог части I («рост ×1,2 и ниже») они прошли. Медиана реестра за тот же год — ×2,69, то есть доля у ts-node просела больше чем на треть, у rxjs — на четверть с лишним, но порог об этом не спрашивает. Отсеивает их только дата выпуска — и это единственная строка снимка, которую нельзя ни ускорить, ни подделать установками.
reflect-metadata попал в тот же список с вердиктом «решает человек»: 30 месяцев тишины при 48 миллионах загрузок в неделю. Здесь машина честно останавливается — законченный пакет от брошенного она не отличает. К этой паре мы вернёмся, когда будем разбирать, что из напечатанного врёт.
Медиана ×2,69 посчитана по 209 пакетам за недели 6–12 сентября 2025 и 2026 года, а загрузки в снимке — за 21–27 сентября. Для порядка величины это годится, для сравнения с точностью до сотых — нет: недели разные.
Список имён аудитор берёт из package.json, а версии — нет. В package.json записан диапазон: крышка ^1.4.2 разрешает всё до 2.0.0, тильда ~1.4.2 — только патчи. Какая версия из диапазона легла на диск, решает установщик, и решение он записывает в lock-файл: диапазон пишет человек, версию выбирает установщик. Аудитор, который сравнит с реестром диапазон, будет судить о коде, которого в проекте нет.
В pnpm-lock.yaml решения лежат в разделе importers — по разделу на каждый пакет монорепозитория. Читать оттуда надо четыре уровня отступов, и по дороге ждут две особенности. Первая: у версии бывает хвост в скобках — 4.0.4(@nestjs/common@11.2.2(…))(rxjs@7.8.2). В скобках записаны соседи, под которых pnpm подобрал эту установку; сама версия — голова до первой скобки. Вторая: свои пакеты монорепозитория стоят в замке как link:../../packages/db, и спрашивать о них реестр не о чем.
| имя | в package.json | в pnpm-lock.yaml | аудитор берёт |
|---|---|---|---|
@jsp/db | workspace:* | link:../../packages/db | пропускает: свой пакет |
@nestjs/config | ~4.0.2 | 4.0.4(…)(rxjs@7.8.2) | 4.0.4 |
drizzle-orm | ~0.44.5 | 0.44.7(@types/pg@8.23.1)(pg@8.16.3) | 0.44.7 |
zod | ~4.1.8 | 4.1.13 | 4.1.13 |
Разбирать YAML аудитор будет своим проходом по отступам, а не парсером из npm. Причина прямо по теме статьи: нужны четыре уровня одного раздела, и лишняя зависимость в скрипте, который считает чужие зависимости, выглядела бы издевательством. Плата за это — узость: разбор, не нашедший importers или нужного пакета, обязан сказать об этом вслух, а не вернуть пустой список, который прочитается как «зависимостей нет».
Свой разбор чужого формата живёт ровно до первой правки этого формата. Такой код пишут с расчётом на громкий отказ, а не на аккуратную работу в неизвестных случаях.
Дальше всё складывается само: имена из package.json, версии из замка, поля из снимка, вопросы из части I. Проверять будем apps/api — боевой API самого учебника. У вашего проекта из главы 14, который вы уже выкатили, зависимостей меньше, но вопросы к ним ровно те же.
Коды выхода у аудитора разведены нарочно: 0 — находок нет, 1 — есть, 2 — упал сам аудитор. Без третьего кода обрыв сети посреди проверки прочитался бы как находка, а это худший вид вранья: тот, который выглядит как работа. Снимок реестра лежит рядом файлом, дата задана флагом — иначе вывод поплыл бы через месяц сам собой. Прогноз: сколько зависимостей не пройдут вопросы 1 и 2 и сколько из двадцати одной мешают поднять Nest до 12?
/* * Аудитор зависимостей сервера: вопросы package-health, заданные машиной про * каждую строку раздела dependencies одного пакета монорепозитория. * * Два источника: registry.npmjs.org (packument — dist-tags, time, maintainers, * peerDependencies последней версии) и api.npmjs.org (загрузки за неделю). * Установленную версию берём из pnpm-lock.yaml, а не из package.json: там * диапазон, а стоит на диске одно. * * node audit.mjs живые запросы * node audit.mjs --save snapshot.json живые запросы и снимок в файл * node audit.mjs --snapshot snapshot.json --today ГГГГ-ММ-ДД по снимку * * Коды выхода разведены нарочно: 0 — находок нет, 1 — есть, 2 — упал сам * аудитор. Без третьего кода обрыв сети посреди проверки читался бы как находка. */ import { readFileSync, writeFileSync } from 'node:fs'; process.on('uncaughtException', (err) => { console.error(`аудит не доведён до конца: ${err?.stack ?? err}`); process.exit(2); }); // путь пакета в монорепозитории — им же он назван в importers замка const ИМПОРТЕР = 'apps/api'; // по чьему мажору сверяем peer: каркас задаёт версию всем, кто к нему цепляется const КАРКАС = '@nestjs/core'; // часть пакетов объявляет peer только на @nestjs/common, а мажор у них с core общий const КАРКАС_КЛЮЧИ = ['@nestjs/core', '@nestjs/common']; // во сколько раз за год выросли недельные загрузки у середины реестра const МЕДИАНА_РОСТА = 2.69; const ТИШИНА_МЕСЯЦЕВ = 12; const РОСТ_НИША = 1.2; const флаги = { snapshot: null, save: null, today: null }; for (let i = 2; i < process.argv.length; i += 1) { const имя = process.argv[i].replace(/^--/, ''); if (!Object.hasOwn(флаги, имя)) throw new Error(`неизвестный аргумент ${process.argv[i]}`); флаги[имя] = process.argv[i + 1]; i += 1; } const день = (метка) => new Date(метка).toISOString().slice(0, 10); const сдвиг = (день_, дней) => new Date(new Date(`${день_}T00:00:00Z`).getTime() + дней * 86400000).toISOString().slice(0, 10); /* * Неделя загрузок кончается позавчера: api.npmjs.org досчитывает сутки не * сразу, и «вчера» отдаёт то полную неделю, то неполную. Год назад — ровно * 364 дня, чтобы неделя легла на те же дни недели: у недельных загрузок * будни и выходные различаются в разы. */ const СЕГОДНЯ = флаги.today ?? день(Date.now()); const НЕДЕЛЯ = [сдвиг(СЕГОДНЯ, -8), сдвиг(СЕГОДНЯ, -2)]; const НЕДЕЛЯ_ГОД_НАЗАД = [сдвиг(НЕДЕЛЯ[0], -364), сдвиг(НЕДЕЛЯ[1], -364)]; // -------------------------------------------------------------- проект /* * Замок читается своим разбором по отступам, а не парсером YAML: аудитору * нужны четыре уровня одного раздела, и лишний пакет в таком скрипте — сам * предмет разговора. Чужой формат за это мстит, поэтому разбор узкий: не * увидел importers или пакета — говорит об этом, а не возвращает пустоту. */ function изЗамка(текст, импортер) { const версии = new Map(); let вImporters = false; let свой = false; let вDependencies = false; let имя = null; for (const строка of текст.split('\n')) { if (/^\S/.test(строка)) { вImporters = строка.startsWith('importers:'); свой = false; continue; } if (!вImporters || !строка.trim() || строка.trimStart().startsWith('#')) continue; const отступ = строка.length - строка.trimStart().length; const тело = строка.trim(); if (отступ === 2) { свой = тело === `${импортер}:`; вDependencies = false; continue; } if (!свой) continue; if (отступ === 4) { вDependencies = тело === 'dependencies:'; continue; } if (!вDependencies) continue; if (отступ === 6) { имя = тело.replace(/:$/, '').replace(/^'|'$/g, ''); continue; } // version: 11.2.2(reflect-metadata@0.2.2)(rxjs@7.8.2) — в скобках подобранные peer'ы, // аудитору нужна голова до первой скобки if (отступ === 8 && тело.startsWith('version:') && имя) версии.set(имя, тело.slice('version:'.length).trim().split('(')[0]); } if (версии.size === 0) throw new Error(`в замке нет пакета ${импортер} с разделом dependencies`); return версии; } const проект = JSON.parse(readFileSync('project/package.json', 'utf8')); const замок = изЗамка(readFileSync('project/pnpm-lock.yaml', 'utf8'), ИМПОРТЕР); const диапазоны = проект.dependencies ?? {}; const свои = Object.keys(диапазоны).filter((имя) => диапазоны[имя].startsWith('workspace:')); const имена = Object.keys(диапазоны).filter((имя) => !свои.includes(имя)).sort(); const пропало = имена.filter((имя) => !замок.has(имя)); if (пропало.length) throw new Error(`в замке нет версий: ${пропало.join(', ')} — package.json и pnpm-lock.yaml разошлись`); // -------------------------------------------------------------- источники const пауза = (мс) => new Promise((готово) => setTimeout(готово, мс)); const ПОВТОРЫ = [5000, 15000, 45000]; const первый = new Set(); async function спросить(адрес) { const хост = new URL(адрес).host; for (let попытка = 0; ; попытка += 1) { const ответ = await fetch(адрес, { headers: { 'user-agent': 'jsp-deps-audit' } }); // первый запрос к каждому хосту называет себя: иначе «ничего не нашлось» и // «нас не пустили» выглядят одинаково, а страница входа Wi-Fi отвечает 200 и не тем if (!первый.has(хост)) { первый.add(хост); console.log(`${адрес} → ${ответ.status} ${ответ.headers.get('content-type')?.split(';')[0]}`); } if (ответ.ok) return ответ.json(); // 429 приходит уже на третьем десятке запросов подряд: это не отказ, а просьба подождать if (ответ.status !== 429 || попытка >= ПОВТОРЫ.length) throw new Error(`${адрес} ответил ${ответ.status}`); console.log(` ${хост} ответил 429, жду ${ПОВТОРЫ[попытка] / 1000} с`); await пауза(ПОВТОРЫ[попытка]); } } async function живьём(имена_) { const снимок = { снято: СЕГОДНЯ, неделя: НЕДЕЛЯ, годНазад: НЕДЕЛЯ_ГОД_НАЗАД, пакеты: {} }; for (const имя of имена_) { // в адресе packument косая черта области экранируется, у загрузок — нет const пакет = await спросить(`https://registry.npmjs.org/${имя.replace('/', '%2f')}`); const latest = пакет['dist-tags']?.latest; const выпуск = пакет.versions?.[latest] ?? {}; await пауза(1200); const неделя = await спросить(`https://api.npmjs.org/downloads/point/${НЕДЕЛЯ.join(':')}/${имя}`); await пауза(1200); const год = await спросить(`https://api.npmjs.org/downloads/point/${НЕДЕЛЯ_ГОД_НАЗАД.join(':')}/${имя}`); снимок.пакеты[имя] = { latest, // возраст считают по времени выпуска САМОЙ версии latest; time.modified // двигает любая публикация, в том числе чужой предварительной ветки выпущена: пакет.time?.[latest] ?? null, правлен: пакет.time?.modified ?? null, deprecated: выпуск.deprecated ?? null, сопровождают: (пакет.maintainers ?? []).map((м) => м.name), peer: выпуск.peerDependencies ?? null, неделя: неделя.downloads, неделяГодНазад: год.downloads, }; } return снимок; } // -------------------------------------------------------------- вопросы /** Полных календарных месяцев между двумя днями — как в health() части I. */ function месяцевМежду(от, до) { const а = new Date(от); const б = new Date(до); const месяцев = (б.getUTCFullYear() - а.getUTCFullYear()) * 12 + б.getUTCMonth() - а.getUTCMonth(); return б.getUTCDate() < а.getUTCDate() ? месяцев - 1 : месяцев; } const мажор = (версия) => Number(версия.split('.')[0]); const предварительная = (версия) => версия.includes('-'); /* * Какие мажоры пускает строка peer. Разбор нарочно узкий: «^11.0.0 || ^12.0.0» * и голая версия. Всё остальное — не молчаливое «не пускает», а null: аудитор, * который принял непонятую строку за отказ, врал бы уверенным голосом. */ function мажорыPeer(строка) { const части = строка.split('||').map((ч) => ч.trim()); const итог = []; for (const часть of части) { const совпало = /^\^?(\d+)\.\d+\.\d+$/.exec(часть); if (!совпало) return null; итог.push(Number(совпало[1])); } return итог; } function здоровье(п, сегодня) { if (п.deprecated) return `снят автором: ${п.deprecated}`; if (предварительная(п.latest)) return `latest — предварительная версия ${п.latest}`; const тишина = месяцевМежду(п.выпущена, сегодня); if (тишина > ТИШИНА_МЕСЯЦЕВ) return `без выпуска ${тишина} мес. — законченный или брошенный, решает человек`; const рост = п.неделя / п.неделяГодНазад; if (рост <= РОСТ_НИША) return `рост ×${рост.toFixed(2)} при медиане ×${МЕДИАНА_РОСТА} — ниша уходит`; return null; } /** «1 мажор», «2 мажора», «8 мажоров» — иначе отчёт читается как черновик. */ const мажоров = (n) => `${n} ${n % 10 === 1 && n % 100 !== 11 ? 'мажор' : n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 12 || n % 100 > 14) ? 'мажора' : 'мажоров'}`; /** Пускает ли пакет мажор каркаса: null — peer про каркас ничего не говорит. */ function держит(имя, п, мажорКаркаса) { if (КАРКАС_КЛЮЧИ.includes(имя)) return null; for (const ключ of КАРКАС_КЛЮЧИ) { const peer = п.peer?.[ключ]; if (!peer) continue; const пускает = мажорыPeer(peer); if (пускает === null) return `peer ${ключ} «${peer}» не разобран — смотреть глазами`; if (!пускает.includes(мажорКаркаса)) return `peer ${ключ} ${пускает.join(', ')} — мажора ${мажорКаркаса} среди них нет`; } return null; } // -------------------------------------------------------------- отчёт const снимок = флаги.snapshot ? JSON.parse(readFileSync(флаги.snapshot, 'utf8')) : await живьём(имена); if (флаги.save) { // по пакету на строку: снимок читают глазами рядом с отчётом, а JSON.stringify // с отступом растягивает двадцать пакетов на три сотни строк const строки = Object.entries(снимок.пакеты).map(([имя, п]) => ` ${JSON.stringify(имя)}: ${JSON.stringify(п)}`); const шапка = Object.entries(снимок).filter(([ключ]) => ключ !== 'пакеты').map(([ключ, з]) => ` ${JSON.stringify(ключ)}: ${JSON.stringify(з)}`); writeFileSync(флаги.save, `{\n${шапка.join(',\n')},\n "пакеты": {\n${строки.join(',\n')}\n }\n}\n`); console.log(`снимок записан: ${флаги.save}`); } const нетВСнимке = имена.filter((имя) => !снимок.пакеты[имя]); if (нетВСнимке.length) throw new Error(`в снимке нет: ${нетВСнимке.join(', ')}`); const мажорКаркаса = мажор(снимок.пакеты[КАРКАС].latest); console.log(`\n${ИМПОРТЕР}: зависимостей из реестра ${имена.length}, своих пропущено ${свои.length}, devDependencies (${Object.keys(проект.devDependencies ?? {}).length}) в этот раз не берём`); console.log(`снимок за ${снимок.снято}, загрузки за ${снимок.неделя.join('..')} против ${снимок.годНазад.join('..')}, медиана роста ×${МЕДИАНА_РОСТА}, сегодня ${СЕГОДНЯ}`); console.log(`каркас ${КАРКАС}: установлен ${замок.get(КАРКАС)}, latest ${снимок.пакеты[КАРКАС].latest}\n`); const больные = []; const держат = []; const отстали = []; const одиночки = []; for (const имя of имена) { const п = снимок.пакеты[имя]; const установлено = замок.get(имя); const диагноз = здоровье(п, СЕГОДНЯ); const преграда = держит(имя, п, мажорКаркаса); const рост = `×${(п.неделя / п.неделяГодНазад).toFixed(1)}`; console.log(`${диагноз ? '!' : '·'} ${имя.padEnd(26)}${`${установлено} → ${п.latest}`.padEnd(22)}${рост.padEnd(7)}${диагноз ?? 'жив'}`); if (диагноз) больные.push(`${имя}: ${диагноз}`); if (преграда) держат.push(`${имя}: ${преграда}`); if (п.сопровождают.length === 1) одиночки.push(имя); const отстал = мажор(п.latest) - мажор(установлено); if (отстал > 0) отстали.push(`${имя}: ${установлено} при latest ${п.latest} — ${мажоров(отстал)} позади`); } console.log(`\nжив ли (вопросы 1 и 2) — находок ${больные.length}:`); for (const строка of больные) console.log(` ${строка}`); console.log(`\nдержит мажор ${КАРКАС} — ${держат.length}:`); for (const строка of держат) console.log(` ${строка}`); console.log(`\nотстало от latest на мажор и больше — ${отстали.length}:`); for (const строка of отстали) console.log(` ${строка}`); console.log(`\nв реестре сопровождает один человек — ${одиночки.length} из ${имена.length}:`); console.log(` ${одиночки.join(', ')}`); console.log(`\nвопросы 0, 3, 4 и 5 реестр не знает: на них отвечает человек и пишет ответ на полку`); process.exit(больные.length + держат.length ? 1 : 0);
apps/api: зависимостей из реестра 21, своих пропущено 2, devDependencies (19) в этот раз не берём снимок за 2026-09-29, загрузки за 2026-09-21..2026-09-27 против 2025-09-22..2025-09-28, медиана роста ×2.69, сегодня 2026-09-29 каркас @nestjs/core: установлен 11.2.2, latest 12.1.1 · @aws-sdk/client-s3 3.887.0 → 3.1142.0 ×4.4 жив · @fastify/multipart 9.2.1 → 10.1.2 ×7.2 жив · @fastify/static 10.1.3 → 10.1.5 ×4.4 жив · @nestjs/common 11.2.2 → 12.1.1 ×3.3 жив · @nestjs/config 4.0.4 → 12.0.1 ×3.1 жив · @nestjs/core 11.2.2 → 12.1.1 ×3.3 жив · @nestjs/platform-fastify 11.1.29 → 12.1.1 ×4.3 жив · @nestjs/swagger 11.4.7 → 12.0.2 ×3.2 жив · @nestjs/throttler 6.5.0 → 6.7.1 ×7.6 жив · @trpc/server 11.5.1 → 11.19.0 ×4.2 жив · bcryptjs 3.0.3 → 3.0.3 ×3.2 жив · drizzle-orm 0.44.7 → 0.45.3 ×13.5 жив · fastify 5.11.0 → 5.12.5 ×5.3 жив · jsonwebtoken 9.0.3 → 9.0.3 ×2.8 жив · nestjs-trpc 2.13.0 → 2.13.0 ×4.6 жив · nodemailer 8.0.11 → 10.0.12 ×3.9 жив · pg 8.16.3 → 8.23.0 ×6.1 жив ! reflect-metadata 0.2.2 → 0.2.2 ×2.9 без выпуска 30 мес. — законченный или брошенный, решает человек ! rxjs 7.8.2 → 7.8.2 ×2.0 без выпуска 19 мес. — законченный или брошенный, решает человек · superjson 1.13.3 → 2.2.6 ×3.0 жив · zod 4.1.13 → 4.6.5 ×7.8 жив жив ли (вопросы 1 и 2) — находок 2: reflect-metadata: без выпуска 30 мес. — законченный или брошенный, решает человек rxjs: без выпуска 19 мес. — законченный или брошенный, решает человек держит мажор @nestjs/core — 1: nestjs-trpc: peer @nestjs/core 9, 10, 11 — мажора 12 среди них нет отстало от latest на мажор и больше — 8: @fastify/multipart: 9.2.1 при latest 10.1.2 — 1 мажор позади @nestjs/common: 11.2.2 при latest 12.1.1 — 1 мажор позади @nestjs/config: 4.0.4 при latest 12.0.1 — 8 мажоров позади @nestjs/core: 11.2.2 при latest 12.1.1 — 1 мажор позади @nestjs/platform-fastify: 11.1.29 при latest 12.1.1 — 1 мажор позади @nestjs/swagger: 11.4.7 при latest 12.0.2 — 1 мажор позади nodemailer: 8.0.11 при latest 10.0.12 — 2 мажора позади superjson: 1.13.3 при latest 2.2.6 — 1 мажор позади в реестре сопровождает один человек — 9 из 21: @nestjs/common, @nestjs/core, bcryptjs, jsonwebtoken, nestjs-trpc, nodemailer, pg, reflect-metadata, zod вопросы 0, 3, 4 и 5 реестр не знает: на них отвечает человек и пишет ответ на полку
Находок по здоровью две, и обе — молчание: reflect-metadata (30 месяцев) и rxjs (19). Оба пакета стоят в проекте не по своей воле: их требует сам Nest, и reflect-metadata вдобавок стоит в списке одиночных сопровождающих. Убирать их не из чего, обновлять некуда — вердикт здесь значит «знайте», а не «чините». Остальные проверки здоровья промолчали: пометки deprecated нет ни у одного из двадцати одного, предварительной версии на latest — тоже, а ниже порога роста не упал никто.
Мажор каркаса держит ровно один пакет из двадцати одного — nestjs-trpc. По здоровью он безупречен: выпуск двухмесячной давности, рост ×4,6 при медиане ×2,69. И при этом его строка peer перечисляет мажоры 9, 10 и 11, а двенадцатого среди них нет. Здоровье и совместимость — разные вопросы, и на сервере второй решает больше.
Третий список — отставание — самый длинный и самый безобидный: восемь строк. Четыре строки из восьми — сам каркас, который никуда не поднимется, пока не поднимется nestjs-trpc. Пятая, @nestjs/config, только выглядит его частью: её собственная строка peer пускает и одиннадцатый мажор, так что эту строку можно закрыть хоть сегодня. Остальные три — nodemailer, superjson и @fastify/multipart, к которым каркас отношения не имеет. А девять пакетов из двадцати одного сопровождает в реестре один человек — и первыми в этом списке стоят @nestjs/common и @nestjs/core.
Записанный прогон обязан дважды подряд напечатать одно и то же, иначе он не записывается. Живой реестр этого обещать не может: загрузки пересчитываются каждый день, версии выходят когда угодно, а api.npmjs.org отвечает 429 уже на третьем десятке запросов подряд. Поэтому у аудитора два режима. Без флагов он идёт в сеть и может записать снимок в файл; со снимком и датой он считает то же самое по файлу и печатает одно и то же хоть через год.
Это ровно то же разделение, что у песочницы части I: данные снимаются один раз, помечаются датой и после этого остаются правдой о своём дне. Живой запрос в статье тоже есть — ниже, — но он записан рукой и подписан днём съёмки, а не снят прогонщиком.
Аудитор не решает, что делать. Он сокращает список того, что надо посмотреть глазами, с двадцати одной строки до одиннадцати — и называет причину для каждой.
Поле peerDependencies читателю знакомо: им пакет называет соседа, которого в себе не везёт, — так пакет маршрутов для React требует свою версию React, а библиотека анимации на телефоне требует свою пару. Разница на сервере в том, что сосед у всех общий: есть каркас, и каждый пакет, который в него встраивается, объявляет, с какими его мажорами согласен работать. Поднять каркас можно ровно до того мажора, который пускают все соседи сразу. Аудитору это считать недолго: разобрать строку peer и посмотреть, есть ли в ней нужное число.
Строки ниже сняты с реестра 29.09.2026, все шесть настоящие. Прогноз: какие из них пускают мажор 12?
Шестая строка — >=8 из peerDependencies самого drizzle-orm — разбором не берётся, и это не недоработка примера. Настоящий диапазон semver знает дефисы, звёздочки, x вместо числа и сочетания через пробел; узкий разбор честно отвечает «не знаю». Вариант, при котором непонятая строка молча становится отказом, хуже любой ошибки: отчёт получается уверенный и неверный, а проверить его будет нечем.
Две строки @nestjs/throttler в примере — это один пакет в двух выпусках. У 6.5.0, который стоит в проекте, двенадцатого мажора в списке нет; у 6.7.1, вышедшего 24.09.2026, — есть. То есть преграда, которая была настоящей две недели назад, к дню записи статьи снялась сама: latest пакета двенадцатый мажор уже пускает, и в списке «держит мажор» его нет. Узнать об этом можно было только запросом. Вот он, живьём.
/* * Один пакет, два адреса, живой ответ: ровно те поля, которые читает аудитор. * Каждый запрос называет себя адресом и кодом ответа — проверка обязана * доказать, что спрашивала то, что собиралась, а не страницу входа в Wi-Fi. */ const ИМЯ = '@nestjs/throttler'; const КАРКАС = '@nestjs/core'; // установленная версия и latest: один и тот же вопрос, заданный двум выпускам const УСТАНОВЛЕНО = '6.5.0'; async function спросить(адрес) { const ответ = await fetch(адрес, { headers: { 'user-agent': 'jsp-deps-audit' } }); console.log(`${адрес}\n → ${ответ.status} ${ответ.headers.get('content-type')?.split(';')[0]}`); if (!ответ.ok) throw new Error(`${адрес} ответил ${ответ.status}`); return ответ.json(); } // косая черта области в адресе packument экранируется, у загрузок — нет const пакет = await спросить(`https://registry.npmjs.org/${ИМЯ.replace('/', '%2f')}`); const загрузки = await спросить(`https://api.npmjs.org/downloads/point/last-week/${ИМЯ}`); const latest = пакет['dist-tags'].latest; const день = (метка) => метка.slice(0, 10); // toLocaleString ставит между разрядами узкий неразрывный пробел — в вывод он // уезжает невидимкой, поэтому разряды разделены обычным пробелом const разряды = (число) => String(число).replace(/\B(?=(\d{3})+$)/g, ' '); console.log(`\n${ИМЯ}`); console.log(` latest ${latest}`); console.log(` time[latest] ${день(пакет.time[latest])}`); console.log(` time.modified ${день(пакет.time.modified)}`); console.log(` сопровождают ${пакет.maintainers.map((м) => м.name).join(', ')}`); console.log(` загрузок за ${загрузки.start}..${загрузки.end}: ${разряды(загрузки.downloads)}`); for (const версия of [УСТАНОВЛЕНО, latest]) { console.log(` peer ${КАРКАС} у ${версия}: ${пакет.versions[версия].peerDependencies[КАРКАС]}`); }
https://registry.npmjs.org/@nestjs%2fthrottler → 200 application/json https://api.npmjs.org/downloads/point/last-week/@nestjs/throttler → 200 application/json @nestjs/throttler latest 6.7.1 time[latest] 2026-09-24 time.modified 2026-09-24 сопровождают nestjscore, kamilmysliwiec загрузок за 2026-09-21..2026-09-27: 4 891 139 peer @nestjs/core у 6.5.0: ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 peer @nestjs/core у 6.7.1: ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 || ^12.0.0
Каждый запрос называет себя сам: строка адреса, под ней код ответа и тип содержимого. Это не украшение: «ничего не нашлось» и «нас не пустили» выглядят одинаково, а страница входа в гостиничный Wi-Fi отвечает 200 и отдаёт HTML. Проверка, которая что-то утверждает про реестр, обязана сначала доказать, что разговаривала с реестром.
У nestjs-trpc история другая. Этот пакет — посредник между Nest и tRPC, и глава о транспорте уже предупреждала, что посредник живёт на пересечении версий двух библиотек и что это самое узкое место зависимости. Аудитор превращает предупреждение в число: мажоры 9, 10, 11 — и всё. Пока строка не расширится, сервер стоит на Nest 11, сколько бы ни вышло двенадцатых версий у всех остальных.
Строку peer смотрят до обновления, а не после. После обновления её показывает установщик — отказом ERESOLVE или, что хуже, предложением поставить «как-нибудь» в обход.
Теперь разберём отчёт с другой стороны: какие его числа означают не то, что кажется. Начнём с загрузок. У reflect-metadata их 48 370 417 за неделю, и рост выше медианы — ×2,86. Пакет, судя по цифрам, на подъёме. Посмотрим, кто эти установки делает.
# запущено в apps/api учебника: кто на самом деле приводит пакет в node_modules echo '$ pnpm why reflect-metadata' pnpm why reflect-metadata echo echo '$ pnpm why find-my-way' pnpm why find-my-way
$ pnpm why reflect-metadata
reflect-metadata@0.2.2
├── @jsp/api@0.0.0 (dependencies)
├─┬ @nestjs/common@11.2.2
│ ├── @jsp/api@0.0.0 (dependencies)
│ ├─┬ @nestjs/config@4.0.4
│ │ └── @jsp/api@0.0.0 (dependencies)
│ ├─┬ @nestjs/core@11.2.2
│ │ ├── @jsp/api@0.0.0 (dependencies)
│ │ ├─┬ @nestjs/platform-fastify@11.1.29
│ │ │ └── @jsp/api@0.0.0 (dependencies)
│ │ ├─┬ @nestjs/swagger@11.4.7
│ │ │ └── @jsp/api@0.0.0 (dependencies)
│ │ ├─┬ @nestjs/testing@11.2.2
│ │ │ └── @jsp/api@0.0.0 (devDependencies)
│ │ ├─┬ @nestjs/throttler@6.5.0
│ │ │ └── @jsp/api@0.0.0 (dependencies)
│ │ └─┬ nestjs-trpc@2.13.0
│ │ └── @jsp/api@0.0.0 (dependencies)
│ ├─┬ @nestjs/mapped-types@2.1.1
│ │ └── @nestjs/swagger@11.4.7 [deduped]
│ ├── @nestjs/platform-fastify@11.1.29 [deduped]
│ ├── @nestjs/swagger@11.4.7 [deduped]
│ ├── @nestjs/testing@11.2.2 [deduped]
│ ├── @nestjs/throttler@6.5.0 [deduped]
│ └── nestjs-trpc@2.13.0 [deduped]
├── @nestjs/core@11.2.2 [deduped]
├── @nestjs/mapped-types@2.1.1 [deduped]
├── @nestjs/swagger@11.4.7 [deduped]
├── @nestjs/throttler@6.5.0 [deduped]
└── nestjs-trpc@2.13.0 [deduped]
Found 1 version of reflect-metadata
$ pnpm why find-my-way
find-my-way@9.7.0
└─┬ @nestjs/platform-fastify@11.1.29
└── @jsp/api@0.0.0 (dependencies)
find-my-way@9.9.0
└─┬ fastify@5.11.0
├── @jsp/api@0.0.0 (dependencies)
└─┬ @nestjs/platform-fastify@11.1.29
└── @jsp/api@0.0.0 (dependencies)
Found 2 versions of find-my-wayВ apps/api reflect-metadata назван один раз — и приведён ещё шестью пакетами: @nestjs/common, @nestjs/core, @nestjs/swagger, @nestjs/throttler, @nestjs/mapped-types и nestjs-trpc. Каждый проект с Nest даёт такую же картину, и в счётчике загрузок это выглядит как спрос на сам пакет. У find-my-way видно совсем чисто: его не называет никто, а в дереве он стоит дважды, двумя разными версиями, потому что его тянут Fastify и адаптер Nest порознь. Загрузки — это установки, а установки делают соседи.
Второе число, которое врёт, — «8 мажоров позади» у @nestjs/config. Число посчитано верно: установлено 4.0.4, latest — 12.0.1. Только мажоров с пятого по одиннадцатый у этого пакета не было вовсе: после 4.0.4 от 09.04.2026 сразу вышла 12.0.0 от 27.08.2026 — команда Nest перевела пакет на общую с каркасом нумерацию. Восьмёрка означает один шаг, а не восемь. Обратный случай в том же отчёте — @aws-sdk/client-s3: 3.887.0 против 3.1142.0, мажор один и тот же, и аудитор молчит, хотя между версиями больше двухсот миноров.
Третье — само слово latest. Это всего лишь один из тегов, и самое новое лежит на нём не всегда. У drizzle-orm на 29.09.2026 latest — 0.45.3, а на теге rc стоит 1.0.0-rc.4 и на beta — 1.0.0-beta.22; у fastify при latest 5.12.5 на теге next лежит 6.0.0-alpha.4. Обычная установка берёт latest и предварительных версий не видит вовсе, но переписывать раздел полки придётся не когда выйдет очередной минор, а когда единица с тега rc переедет на latest.
Четвёртое — «один сопровождающий». Девять пакетов из двадцати одного в реестре может выпустить один человек, и это не список подозрительных: первыми там стоят @nestjs/common и @nestjs/core — под учётной записью nestjscore. Число считается легко и значит мало: оно говорит, сколько ключей нужно украсть, и ничего не говорит о том, сколько людей пишут код.
И последнее — то, чего в отчёте нет. Список «мешает поднять» собран по двум ключам: @nestjs/core и @nestjs/common. А у @nestjs/platform-fastify 12.1.1 в peer стоят ещё @fastify/static ^10.1.2 и @fastify/multipart ^10.1.0, и второй в проекте — 9.2.1. Эта преграда настоящая, аудитор про неё молчит, и молчит по-честному: он сверяет ровно то, о чём его просили. Отчёт, в котором список назван «полным», был бы пятым враньём — самым дорогим.
Каждое число в отчёте стоит прочитать вслух вместе со словом «потому что». Если продолжения не находится, число красивое, а не полезное.
Аудитор отвечает на вопросы 1 и 2, добавляет к ним серверный вопрос про peer и останавливается. Вопрос 0 («язык и платформа уже умеют»), вопрос 3 («цена»), вопрос 4 («не навязывает ли пакет устройство проекта») и вопрос 5 («собирали ли вы это сами») из реестра не берутся никак: на них отвечает человек, один раз, и записывает ответ туда, где его найдут через год. В части I нулевой вопрос функция тоже не решала сама — она читала поле platform, вписанное в снимок рукой.
В этом репозитории такое место есть: plans/shelves-ledger.json — книга учёта полок. На 29.09.2026 в ней 125 строк: 115 — четыре полки учебника, ещё десять пришли из тематических статей, где пакет назван вне полки. У каждой строки имя пакета, версия, дата проверки и растяжки — условия, при которых строка перестаёт быть правдой. Растяжек двадцать видов: вышел мажор (npm-major), пакет пометили deprecated, выпуска нет столько-то месяцев, строка peer поменялась, тег latest стал предварительной версией, вес после сборщика ушёл дальше записанного допуска, Safari догнал полифил.
Гоняет по книге эти условия scripts/check-shelf.mjs — по смыслу тот же аудитор, только вопросы у него берутся из книги, а не из package.json. Коды выхода те же три: 0 — ничего не сработало, 1 — сработало или строка старше года, 2 — сам скрипт упал. В сборку и в CI он не входит намеренно: сеть делает проверку нестабильной, а сборка, которая краснеет от чужого 429, учит только не смотреть на красное. Его запускают раз в квартал и читают глазами.
Так закрывается круг. Метод из части I — шесть вопросов по порядку; аудитор этой статьи — те два из них, что считаются по данным реестра, плюс серверный peer; книга учёта — остальные четыре, отвеченные человеком и подписанные датой. А имена с версиями и ценой для сервера собраны в одном месте — на полке сервера, следующей и последней статье части.
Дата проверки в книге важнее самой проверки. Строка без даты через год выглядит так же уверенно, как в день записи, — и это единственный способ соврать, не написав ни одного неверного слова.
- Аудитор зависимостей — это метод package-health, которому дали сеть: имена он берёт из
package.json, версии из lock-файла, а ответы — из packumentregistry.npmjs.orgи счётчикаapi.npmjs.org. Всего восемь полей. - Возраст считают по
time[версия latest].time.modifiedдвигают любые правки записи о пакете: уts-nodeпосле10.9.2не вышло ни одной версии, аmodifiedушёл вперёд почти на два года. - Версию берут из замка, а не из диапазона, и отрезают хвост в скобках: в нём записаны соседи, под которых pnpm подобрал установку. Строка
link:— свой пакет монорепозитория, реестру его не показывают. - Серверный вопрос, которого нет в части I, —
peerDependencies: мажор каркаса выбирает самый отставший сосед. В боевом API учебника это один пакет из двадцати одного, и по здоровью он безупречен. - Непонятую строку диапазона проверка обязана назвать непонятой. Молчаливое «не пускает» превращает отчёт в уверенное враньё, а обрыв связи должен давать свой код выхода, а не числиться находкой.
- Загрузки считают установки соседей, разница мажоров считает номера, а latest — всего лишь один из тегов. Вопросы 0, 3, 4 и 5 из реестра не берутся: их отвечает человек и записывает в книгу учёта полок с датой проверки.
1/4Почему возраст пакета считают по time[версия latest], а не по time.modified?
2/4Аудитор не разобрал строку peer вида >=8 и вернул null вместо списка мажоров. Чем это лучше, чем считать, что пакет нужный мажор не пускает?
3/4В pnpm-lock.yaml у @nestjs/config записано 4.0.4(@nestjs/common@11.2.2(…))(rxjs@7.8.2). Что аудитору делать с хвостом в скобках?
4/4Загрузки reflect-metadata — 48 миллионов в неделю, рост выше медианы, а выпуска не было 30 месяцев. Что из этих цифр говорит о будущем пакета?
Самое новое лежит не на latest
Проверка ниже ищет мажор, который уже вышел, но ещё не стал latest, — и смотрит при этом только на тег next. Имена тегов пакет придумывает сам: у одного это next, у другого rc и beta, у третьего canary. Почините проверку так, чтобы она смотрела на все теги, кроме latest. Данные в примере настоящие, снимок от 29.09.2026.
Перебирать надо весь набор тегов, а не два известных имени. Object.entries отдаёт пары «тег — версия», latest из них убирается по имени, остальное сравнивается по мажору.
После правки у drizzle-orm находятся beta 1.0.0-beta.22 и rc 1.0.0-rc.4, у fastify — next 6.0.0-alpha.4, а у @nestjs/core и @trpc/server впереди по-прежнему пусто. В наборе есть и ловушка: тег rc у @nestjs/core держит 6.0.0-rc.1 — версию много старше latest. Теги живут сколько угодно и назад не убираются, поэтому сравнивать надо мажоры, а не доверять имени тега.
Версия из замка, а не из диапазона
Аудитор ниже берёт версию из package.json и просто снимает с неё крышку или тильду. Из-за этого он спрашивает реестр про версии, которых в проекте нет, а про свой пакет монорепозитория спрашивает и подавно зря. Перепишите установлено так, чтобы версия бралась из замка: хвост в скобках отрезается, запись link: означает свой пакет и null. Ожидаемый вывод: 4.0.4 у @nestjs/config, 0.44.7 у drizzle-orm, 4.1.13 у zod и строка про свой пакет у @jsp/db.
Диапазон в решении не нужен вовсе — он остаётся в отчёте как пояснение, а версию даёт только замок.
Разница видна на трёх строках из пяти: ~4.0.2 против установленной 4.0.4, ~0.44.5 против 0.44.7, ~4.1.8 против 4.1.13. Все три расхождения — обычная работа тильды, и именно поэтому сравнивать с реестром надо вторую колонку: вопрос «жив ли пакет» задают про тот код, который лежит в node_modules.