В почти каждом крупном репозитории живёт модуль, который никто не импортирует годами, но все боятся тронуть. Он называется по-разному: utils-old, helpers-v1, common. Лог у него зелёный, тесты когда-то проходили, а автор давно работает в другой компании. Рано или поздно встаёт вопрос: удалить или оставить. Ответ на него существует, и он измеримый: мёртвый код ищут инструментами статического анализа, проверяют покрытием и телеметрией, помечают, выдерживают паузу и только потом удаляют. Подход "покажется, что не используется, и снесу" выглядит быстрее, но расплачивается за него продакшен. Разберём, какие анализаторы умеют находить неиспользуемый код, как отличить настоящий мёртвый модуль от живой ветки со сложной судьбой и в каком порядке наводить порядок без инцидентов.
Чем мёртвый код мешает проекту и почему сам себя не удалит
Мёртвый код редко выглядит проблемой. Он же просто лежит, не мешает. На деле каждый неиспользуемый кусок работает против проекта каждый день. Поиск по репозиторию выдаёт мёртвые примеры, и новые разработчики копируют не тот шаблон. Рефакторинг тратит силы на сущности, которых нет ни в одном пользовательском сценарии. Сборка тащит лишние файлы, а вместе с ними и зависимости, у которых случаются найденные уязвимости: код, который не работает, всё равно висит в отчётах безопасности и требует обновлений. Когнитивная нагрузка тоже стоит денег: человек, читающий модуль, обязан держать в голове вопрос "а это вообще живое", и ответа у него нет.
Хуже всего то, что мёртвый код растёт сам и добровольно не уходит. Под устаревшую функцию пишут обходные пути, обходные пути обрастают костылями, и через два года одна мёртвая функция превращается в мёртвый квартал с собственной инфраструктурой. Удаление "когда-нибудь потом" не наступает, потому что у задачи нет владельца и нет дедлайна. Поэтому гигиена репозитория обязана быть регулярной процедурой с инструментами, а не актом вдохновения в свободную пятницу.
Почему удаление по ощущениям заканчивается инцидентом в продакшене
Типичная сцена знакома многим. Разработчик смотрит на эндпоинт выгрузки отчёта и говорит: этим никто не пользуется, интерфейс давно переведён на новый формат. Удаляет. Через три месяца бухгалтерия открывает квартальную отчётность и обнаруживает пустоту: выгрузку никто не вызывал из фронтенда, её вызывала книга макросов в таблице, которую использует один человек четыре раза в год. Формально код не вызывался, по сути он работал.
У истории простой моральный итог: ощущение разработчика это не доказательство. Человеческая память держит два-три года и только собственный опыт. Стажёр не знает про ручной сценарий из инструкции 2019 года, руководитель не помнит про внешнего клиента с выгрузкой по расписанию. После смены половины команды слова "вроде бы никто не использует" не стоят ничего. Каждый кандидат на удаление обязан иметь улики, собранные инструментами, а не уверенным тоном на планировании.
Вторая история того же рода: фоновая выгрузка работала по расписанию, её удалили вместе с модулем отчётов, и три месяца никто не замечал, пока потребитель не прислал письмо о пустом архиве. Расписание тоже вызов, только его не видно в коде продукта, оно живёт в планировщике на другой машине. Улики обязаны собираться там, где живёт реальность: в планировщиках, конфигурациях, книгах макросов, внешних заказах на выгрузку.
Обратная ошибка встречается не реже: код удаляют без последствий, но вместе с ним уходит живой модуль, который анализатор не увидел из-за косвенного вызова. Оба сценария лечатся одним способом: доказательной базой из нескольких независимых источников, о которых ниже.
Инструменты статического анализа для поиска неиспользуемого кода
Анализаторы неиспользуемого кода зрелая категория инструментов, и под каждый стек есть проверенные варианты. В проектах на JavaScript и TypeScript работает knip, который ищет неиспользуемые файлы, зависимости и экспорты сразу, а рядом с ним ts-prune, перечисляющий непублично висящие экспорты. Для локальных переменных и импортов хватает правила no-unused-vars из ESLint. В Python работает vulture, назначающий каждой находке оценку уверенности от 0 до 100. В Java поставляются утилиты инспекций среды разработки, находящие неиспользуемые объявления, и штатный анализатор зависимостей jdeps, доступный с восьмой версии JDK. В Go точку ставит проверка U1000 из staticcheck, в Rust мёртвый код ловит встроенный линтер dead_code, в C и C++ компиляторы предупреждают через флаг -Wunused, а компоновщик умеет выбрасывать недостижимые секции.
Набор команд для типового аудита выглядит так:
npx knip
npx ts-prune
python -m vulture app/ --min-confidence 80
jdeps --print-module-deps app.jar
staticcheck -checks=U1000 ./...
Первый прогон на крупном проекте обычно выдаёт сотни позиций, и это нормально: анализатор честно перечисляет всё, до чего не смог дотянуться. Важно понимать формат отчёта: инструмент показывает кандидатов, то есть места, для которых он не нашёл пути вызова. Решение об удалении всё равно остаётся за человеком, но спорить с инструментом проще, чем с памятью: по списку можно пройтись, каждого кандидата пометить и вернуться к нему с доказательствами.
Отчёт knip наглядно показывает, что именно висит без потребителей:
Unused files (2)
src/legacy/reportCsv.ts
src/legacy/formatMoney.ts
Unused exports (14)
utils/dates.ts: humanizeWeekday
utils/parse.ts: extractCurrency
Такой формат экономит время на разбор: файл без потребителей удаляют целиком, неиспользуемый экспорт откручивают от модуля, который жив сам по себе. Отдельный пласт находок это зависимости. В зрелом проекте список пакетов разрастается из-за экспериментов и переездов, и часть его давно не нужна: сборка скачивает лишнее, сканеры уязвимостей будоражат команды из-за пакетов, которые ни разу не импортируются. JavaScript-проекты чистят knip и depcheck, Go убирает лишние требования командой go mod tidy, Rust ищет лишние ящики через cargo udeps в ночном канале инструментария. Удаление неиспользуемой зависимости часто ценнее удаления кода: оно срезает целые ветви отчётов безопасности без единого изменения логики.
Покрытие тестами и телеметрия как второе мнение о живости кода
Статический анализ смотрит на граф вызовов внутри репозитория. Второе мнение о коде даёт наблюдение за ним в работе. Инструменты покрытия вроде JaCoCo в Java, istanbul в JavaScript и coverage.py в Python показывают, какие строки исполнялись во время тестов, а метрики исполнения в продакшене отвечают на вопрос, какие строки исполнялись у живых пользователей. Сравнение двух картин сужает список кандидатов драматически: класс, к которому нет обращений ни в тестах, ни в телеметрии за девяносто дней, почти наверняка мёртв.
У метода есть границы, и о них стоит знать заранее. Годовая отчётность не исполняется по девяносто дней, а исполняется в декабре. Аварийные процедуры восстановления зовутся в худший день года. Ручные сценарии администратора не видны в метриках продукта. Поэтому окно наблюдения выбирают с запасом: для регулярных операций достаточно месяца, для сезонных смотрят полный цикл, вплоть до года. И ещё правило: отсутствие вызовов это довод, но не приговор, финальное решение принимает владелец домена, который знает про декабрь.
Практика с цифрами выглядит так. Модуль выгрузки старого формата закрыт тестами на восемьдесят процентов, значит его кто-то проверяет; утилита конвертации из 2018 года не покрыта вовсе, и в телеметрии вызовов ноль за девяносто дней. Первый модуль остаётся в покое, второй идёт в кандидаты. Для спорных случаев работает обёртка: перед удалением в публичный метод ставят счётчик обращений и предупреждение, выпускают релиз и смотрят на график. Ноль обращений за период наблюдения переводит спор в арифметику.
Ловушки динамических вызовов и ложные срабатывания анализаторов
Анализаторы ошибаются в одну предсказуемую сторону: они называют мёртвым то, что вызывается способом, который инструмент не видит. Список таких способов известен каждому, кто имел дело с рефлексией: вызов метода по строковому имени, сборка объекта контейнером зависимостей, диспетчеризация по данным конфигурации, шаблонизаторы, динамическая загрузка модулей, обработчики сообщений из очередей, входные точки подключаемых плагинов. Для типовых каркасов анализаторы узнают эти механизмы через плагины: knip, например, понимает точки входа популярных сборщиков и каркасов, поэтому не считает мёртвыми обработчики, о которых знает каркас.
Классический слепой случай: диспетчер, который собирает имя функции из строки.
handler = getattr(handlers, "export_" + fmt)
handler(rows)
Для анализатора функции export_csv и export_xml не связаны ни с одним вызовом, хотя вся выгрузка проходит через них каждую ночь. Поэтому перед чисткой динамических участков список кандидатов вручную сверяют с конфигурацией диспетчеризации: если имя собирается из строк, строки тоже часть графа вызовов, просто читать их нужно глазами. Когда каркас самодельный, анализатор слеп, и тут работают ручные средства: белый список файлов для исключения, маркеры-аннотации для "оставить в покое", явные указания точек входа в конфигурации инструмента. Ложное срабатывание это не повод выключить анализатор, а повод описать исключение явно. Так список кандидатов остаётся честным: в нём остаются только те позиции, за которые никто не поручился.
Порядок безопасного удаления мёртвого кода в несколько шагов
Доказательная методика складывается в короткий и повторяемый процесс. Отработанный порядок удаления выглядит так:
- прогнать анализаторы и собрать единый список кандидатов с пометками источников: файл, экспорт, зависимость;
- сверить каждого кандидата с покрытием тестами и телеметрией исполнения за выбранный период наблюдения;
- связать кандидата с владельцем домена и назначить ему статус: к удалению, оставить, требует уточнения;
- пометить выбранные к удалению сущности предупреждением об устаревании и ждать один-два релиза;
- удалить маленькими порциями, каждую порцию проверить сборкой, тестами и метриками эксплуатации;
- убрать следы: конфигурации, документацию, упоминания в инструкциях и скриптах.
Предупреждение об устаревании перед удалением ставит контрольную точку: если кто-то всё-таки использует код, он увидит сообщение до того, как код исчезнет. В Python это выглядит так:
import warnings
def export_csv(rows):
warnings.warn("export_csv устарел, используйте export_excel",
DeprecationWarning)
Маленькие порции нужны по той же причине, по которой маленькими бывают релизы: откат одного пул-реквеста это минута, откат коммита, в котором удалено четыреста файлов, это следствие, разбор и полдня работы. Система контроля версий хранит всю историю, поэтому удаление обратимо по определению, и страшиться его не нужно. Страшно другое: репозиторий, из которого годами ничего не удаляли.
Когда код лучше оставить в покое вместо удаления
Не каждый молчаливый модуль мёртв. Регуляторные требования иногда предписывают хранить обработчики старых форматов на случай проверки давних операций. Процедуры восстановления и аварийные сценарии молчат годами и обязаны молчать, их живость проверяют учениями, а не телеметрией. Экзотические, но платящие клиенты на старых протоколах остаются клиентами. Отличие зрелой команды в том, что она хранит такое осознанно: у модуля есть владелец, пометка в документации и причина, записанная рядом. Молчаливое хранение "на всякий случай" отличается от архивного тем, что через год никто не помнит, какой именно случай имелся в виду, и модуль снова числится в подозреваемых.
Практичный компромисс для спорных случаев: перенести код в явно названную область архива, отдельный пакет или ветку долгосрочного хранения, и удалить его из основного дерева сборки. Инженеры перестают спотыкаться о мёртвое каждый день, а при внезапной необходимости история доступна за минуты.
Регулярная гигиена превращает удаление мёртвого кода из проекта в рутину: анализаторы гоняют в конвейере сборки, список кандидатов поддерживается актуальным, каждая квартальная чистка забирает по чуть-чуть. Показатель числа мёртвых позиций в отчёте двигается вниз, и в какой-то момент анализатор отвечает, что нечего удалять. Это и есть цель: не героическая зачистка на месяц, а спокойный репозиторий, в котором всё, что лежит, работает. Мёртвый код возвращается только туда, где его боятся.