Свой аудитор зависимостей: реестр, загрузки, возраст — и что из этого врёт

пакеты≈ 13 минут

Здесь будет встроен видео-разбор темы (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.latestpackumentкакая версия приедет по npm i имя
time[latest]packumentсколько месяцев пакет молчит
time.modifiedpackumentни на какой — см. ниже
versions[latest].deprecatedpackumentснят ли пакет самим автором
maintainerspackumentсколько людей могут выпустить версию
versions[latest].peerDependenciespackumentс какими мажорами соседей пакет живёт
downloads за неделюapi.npmjs.orgдоля в нише
downloads за ту же неделю год назадapi.npmjs.orgрост против медианы реестра

Строка time.modified выглядит как самая удобная: одно число, и сразу видно, когда пакет трогали. Она и есть главная ловушка. Дату выпуска в реестре держит time[версия] — отдельная метка на каждую опубликованную версию, — а modified двигают любые изменения записи о пакете, в том числе те, после которых новой версии не появилось вовсе.

Ниже — снимок шести пакетов, снятый 29.09.2026: обе даты и загрузки за одну и ту же неделю сентября 2026 и 2025 года. Живых запросов в коде нет, сети в песочнице тоже. Считает вердикт функция тем же способом, что health() из части I: месяцы календарно, вопросы по порядку, первый «нет» отвечает за весь пакет. Прогноз: у каких пакетов две даты дают разные ответы и насколько разные?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Расходятся две даты у трёх пакетов из шести, но дальше всего — у 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/dbworkspace:*link:../../packages/dbпропускает: свой пакет
@nestjs/config~4.0.24.0.4(…)(rxjs@7.8.2)4.0.4
drizzle-orm~0.44.50.44.7(@types/pg@8.23.1)(pg@8.16.3)0.44.7
zod~4.1.84.1.134.1.13

Разбирать YAML аудитор будет своим проходом по отступам, а не парсером из npm. Причина прямо по теме статьи: нужны четыре уровня одного раздела, и лишняя зависимость в скрипте, который считает чужие зависимости, выглядела бы издевательством. Плата за это — узость: разбор, не нашедший importers или нужного пакета, обязан сказать об этом вслух, а не вернуть пустой список, который прочитается как «зависимостей нет».

Свой разбор чужого формата живёт ровно до первой правки этого формата. Такой код пишут с расчётом на громкий отказ, а не на аккуратную работу в неизвестных случаях.

Дальше всё складывается само: имена из package.json, версии из замка, поля из снимка, вопросы из части I. Проверять будем apps/api — боевой API самого учебника. У вашего проекта из главы 14, который вы уже выкатили, зависимостей меньше, но вопросы к ним ровно те же.

Коды выхода у аудитора разведены нарочно: 0 — находок нет, 1 — есть, 2 — упал сам аудитор. Без третьего кода обрыв сети посреди проверки прочитался бы как находка, а это худший вид вранья: тот, который выглядит как работа. Снимок реестра лежит рядом файлом, дата задана флагом — иначе вывод поплыл бы через месяц сам собой. Прогноз: сколько зависимостей не пройдут вопросы 1 и 2 и сколько из двадцати одной мешают поднять Nest до 12?

прогон node 26.5.0 · 29 сентября 2026
Аудитор проходит по dependencies боевого API учебника: снимок реестра в файле, дата задана флагом
/*
 * Аудитор зависимостей сервера: вопросы 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);
node audit.mjs --snapshot snapshot.json --today 2026-09-29

Находок по здоровью две, и обе — молчание: 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?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Шестая строка — >=8 из peerDependencies самого drizzle-orm — разбором не берётся, и это не недоработка примера. Настоящий диапазон semver знает дефисы, звёздочки, x вместо числа и сочетания через пробел; узкий разбор честно отвечает «не знаю». Вариант, при котором непонятая строка молча становится отказом, хуже любой ошибки: отчёт получается уверенный и неверный, а проверить его будет нечем.

Две строки @nestjs/throttler в примере — это один пакет в двух выпусках. У 6.5.0, который стоит в проекте, двенадцатого мажора в списке нет; у 6.7.1, вышедшего 24.09.2026, — есть. То есть преграда, которая была настоящей две недели назад, к дню записи статьи снялась сама: latest пакета двенадцатый мажор уже пускает, и в списке «держит мажор» его нет. Узнать об этом можно было только запросом. Вот он, живьём.

запись с машины: терминал автора, 29.09.2026, node 26.5.0; живые ответы registry.npmjs.org и api.npmjs.org — назавтра числа другие · 29 сентября 2026
Живой ответ реестра по одному пакету: каждый запрос называет свой адрес и код ответа
/*
 * Один пакет, два адреса, живой ответ: ровно те поля, которые читает аудитор.
 * Каждый запрос называет себя адресом и кодом ответа — проверка обязана
 * доказать, что спрашивала то, что собиралась, а не страницу входа в 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[КАРКАС]}`);
}
node 'реестр.mjs'

Каждый запрос называет себя сам: строка адреса, под ней код ответа и тип содержимого. Это не украшение: «ничего не нашлось» и «нас не пустили» выглядят одинаково, а страница входа в гостиничный Wi-Fi отвечает 200 и отдаёт HTML. Проверка, которая что-то утверждает про реестр, обязана сначала доказать, что разговаривала с реестром.

У nestjs-trpc история другая. Этот пакет — посредник между Nest и tRPC, и глава о транспорте уже предупреждала, что посредник живёт на пересечении версий двух библиотек и что это самое узкое место зависимости. Аудитор превращает предупреждение в число: мажоры 9, 10, 11 — и всё. Пока строка не расширится, сервер стоит на Nest 11, сколько бы ни вышло двенадцатых версий у всех остальных.

Строку peer смотрят до обновления, а не после. После обновления её показывает установщик — отказом ERESOLVE или, что хуже, предложением поставить «как-нибудь» в обход.

Теперь разберём отчёт с другой стороны: какие его числа означают не то, что кажется. Начнём с загрузок. У reflect-metadata их 48 370 417 за неделю, и рост выше медианы — ×2,86. Пакет, судя по цифрам, на подъёме. Посмотрим, кто эти установки делает.

запись с машины: apps/api учебника, pnpm 11.17.0 и node 26.5.0, 29.09.2026; дерево из pnpm-lock.yaml репозитория · 29 сентября 2026
Кто приводит пакет в node_modules боевого API: прямая зависимость против транзитивных
# запущено в 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
sh 'почему.sh'

В 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-файла, а ответы — из packument registry.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.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Перебирать надо весь набор тегов, а не два известных имени. Object.entries отдаёт пары «тег — версия», latest из них убирается по имени, остальное сравнивается по мажору.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

После правки у 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.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Диапазон в решении не нужен вовсе — он остаётся в отчёте как пояснение, а версию даёт только замок.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Разница видна на трёх строках из пяти: ~4.0.2 против установленной 4.0.4, ~0.44.5 против 0.44.7, ~4.1.8 против 4.1.13. Все три расхождения — обычная работа тильды, и именно поэтому сравнивать с реестром надо вторую колонку: вопрос «жив ли пакет» задают про тот код, который лежит в node_modules.