Копипаста относится к самым незаметным и живучим видам технического долга. Стиль проверяет линтер, типы проверяет tsc, поведение проверяют тесты. А скопированный из соседнего модуля кусок бизнес-логики, чуть подправленный и забытый, спокойно уезжает в main. С этого момента любой баг в нём приходится чинить дважды, только никто об этом не помнит.

Для этого есть PMD CPD (Copy-Paste Detector). Он сравнивает не строки, а поток токенов, поэтому не ломается от форматирования, переносов и прочего шума. Одно но: это Java. Чтобы найти копипасту в TypeScript-проекте, нужно притащить в CI JVM и дистрибутив PMD. Во frontend-пайплайне, где всё остальное ставится одной строкой в package.json, это чемодан без ручки. Вторая проблема серьёзнее: PMD не видит фронтенд. Vue-, Svelte- и Angular-шаблонов для него не существует, а копипасты там хватает.
А инструментов уровня PMD в npm-экосистеме не было вовсе.
Так что я написал свой: clone-alert. Это детектор копипасты уровня PMD CPD для TypeScript, JavaScript, JSX/TSX, Vue, Svelte и Angular: обычный npm-пакет с одной runtime-зависимостью (typescript), без JVM, без бинарников. Внутри лежит портированный алгоритм PMD, оптимизированный под JS, а не переписанный по мотивам. Что это правда порт, а не что-то похожее, проверяют тесты против собственных фикстур PMD. Скорость не была целью, но она стала приятным бонусом: на реальных репозиториях в 10-27 раз быстрее PMD и держит пик памяти в 1.3-2.6 раза ниже (на моём железе).
Как он ищет
Пайплайн классический для токенного CPD:
-
Каждый файл превращается в поток лексических токенов.
-
По окнам фиксированной длины (
--minimum-tokens, по умолчанию 50) считается rolling hash Карпа-Рабина. -
Окна с одинаковым хешем группируются, портированный из PMD
MatchCollectorрасширяет совпадения, отбрасывает вложенные и самопересекающиеся и оставляет максимальные дубли.
Поскольку сравниваются токены, а не текст, детектору всё равно, как код отформатирован, где стоят переносы и сколько наставлено пробелов. Ядро при этом вообще не знает, что такое TypeScript или Vue: оно получает поток токенов и ищет повторяющиеся последовательности. Языки и фреймворки живут отдельными слоями сверху, поэтому каждый из них можно проверять на совместимость с PMD независимо.
Два режима поиска
Клоны принято делить на четыре типа, от точных копий до семантических, написанных по-разному. Токенный CPD, как и PMD, берёт два первых и самых частых: точные копии и копии с переименованием. Под них и заточены два режима.
Строгий поиск (по умолчанию, как в PMD): токены сравниваются как есть. Ловится честная копипаста: скопировал, вставил, разве что отформатировал.
Нормализованный поиск: с флагами --ignore-identifiers и --ignore-literals детектор перестаёт учитывать конкретные имена переменных, строки и числа. Поэтому он находит копипасту, которую пытались замаскировать: переименовали переменные, заменили строки или числа, но структура кода осталась той же. В PMD для TypeScript эти флаги формально есть, но токенизатор их не использует. То есть clone-alert умеет на TypeScript то, чего PMD пока не умеет.
clone-alert --minimum-tokens 100 --ignore-identifiers --ignore-literals --files src
TypeScript-поддержка лучше, чем у эталона
PMD лексит TypeScript рукописной грамматикой, которая тянется с эпохи ES5 и хронически отстаёт от языка. clone-alert лексит настоящим Scanner из компилятора TypeScript, тем же, которым пользуется tsc. Поверх работает слой совместимости, приводящий поток к PMD-гранулярности (включён по умолчанию для .ts/.tsx).
На практике это даёт две вещи.
-
Там, где PMD умеет лексить, clone-alert совпадает с ним потокенно. В репозитории лежат оригинальные тестовые примеры токенизатора PMD для JavaScript и TypeScript. CI сверяет по ним каждый токен: текст, строку, колонку. Все проверки проходят без исключений.
-
Там, где грамматика PMD сломается, clone-alert продолжает работать. Настоящему лексеру всё равно на
satisfies,using, декораторы, template literal types и свежие операторы.
То есть на уровне токенизации разница возникает только в одну сторону: там, где PMD не смог разобрать файл или не поддержал флаг, а clone-alert смог.
Шаблоны: Vue, Svelte, Angular
Инструмент поддерживает шаблоны родными компиляторами фреймворков: @vue/compiler-sfc, svelte (полноценная разметка со Svelte 5+), @angular/compiler. Все три подключены как опциональные peer-зависимости и резолвятся от анализируемого файла: если в проекте стоит свой компилятор, подхватывается он, его версия и его поведение. Если компилятора нет, файл пропускается с предупреждением, а не роняет прогон. Сама утилита ничего фреймворкового в зависимостях не тянет.
Токены шаблона делятся на два слоя, но Angular здесь стоит особняком:
-
Разметка включает теги, директивы, имена атрибутов и статический текст. Для неё используется служебный префикс. Поэтому она не совпадёт с обычным кодом: дубли разметки ищутся только среди разметки.
-
Во Vue и Svelte выражения из
{{ user.name }},:disabled="!canSubmit()",v-if="canSubmit()"и{...}прогоняются через обычный TS-токенизатор без префикса. Поэтому одинаковая логика может совпасть даже между шаблоном и<script>: выражение из интерполяции найдёт близнеца вcomputedсоседнего компонента. -
В Angular отдельный неймспейс получают все токены шаблона, включая выражения. Поэтому дубли находятся между Angular-шаблонами, но не между шаблоном и TypeScript-кодом.
Разметке обычно нужен порог выше, чем коду: она шумнее. Для Vue и Svelte удобно запускать два прохода: код с порогом от 50 до 100, разметку с порогом от 120 до 150.
Бенчмарки: тот же результат, быстрее, меньше памяти
Сравнение с PMD на пяти реальных TypeScript-кодовых базах. Чтобы вход был байт-идентичным, берутся только чистые .ts-файлы, ровно то подмножество, которое умеет лексер PMD.
Совпадение с PMD по парам файлов, которые оба инструмента пометили как дублирующиеся:
|
Репозиторий |
Совпадение с PMD |
|---|---|
|
nestjs/nest |
100% |
|
nrwl/nx |
99.9% |
|
microsoft/playwright |
99.98% |
|
vercel/next.js |
99.2% |
|
angular/components |
95% |
Единственное заметное расхождение, 95% на angular/components, не связано с пропущенными дублями. Там два десятка почти одинаковых demo-файлов таблиц с общим блоком в 398 токенов. Сам блок оба инструмента находят одинаково, токен в токен. Расходятся они только в группировке: файлы взаимозаменяемы, и какие из них свести в одну запись отчёта решается по-разному. Находки идентичны, разошлась только их раскладка по группам.
Скорость и пиковая память на тех же прогонах:
|
Репозиторий |
clone-alert |
PMD CPD |
Ускорение |
Пиковая память |
|---|---|---|---|---|
|
nestjs/nest |
0.7 с |
15.4 с |
23× |
203 МБ против 526 МБ |
|
angular/components |
1.6 с |
41.9 с |
27× |
338 МБ против 632 МБ |
|
microsoft/playwright |
3.6 с |
58.7 с |
16× |
836 МБ против 1.6 ГБ |
|
vercel/next.js |
6.0 с |
73.6 с |
12× |
1.3 ГБ против 1.7 ГБ |
|
nrwl/nx |
8.1 с |
83.2 с |
10× |
2.1 ГБ против 3.2 ГБ |

Baseline
Любой quality gate на живом проекте в первый день находит весь накопленный долг. Baseline позволяет принять его и падать только на новом:
# один раз: зафиксировать существующие дублиclone-alert --files src --baseline .clone-alert-baseline.json --update-baseline# в CI: падать только на новыхclone-alert --files src --baseline .clone-alert-baseline.json --fail-on-violation
Baseline представляет собой сортированный JSON, который обычно коммитят и ревьюят в PR. Клон узнаётся по fingerprint, рассчитанному по токенам без путей и номеров строк: после форматирования или сдвига строк он останется в baseline. Перенос файла тоже не вернёт его в отчёт, хотя при регенерации JSON обновится список files. Отдельного постоянного кеша нет: после поиска утилита просто отфильтровывает уже найденные совпадения.
Что ещё внутри
SARIF и GitHub Action: --format sarif отдаёт отчёт для GitHub Code Scanning: дубли показываются алертами прямо в PR, а стабильные фингерпринты не дают GitHub поднимать алерт заново, когда блок просто переехал. Есть готовый Action: один шаг, SARIF загружается сам.
- uses: BaryshevRS/clone-alert@v1 with: paths: src minimum-tokens: 100
Подавление намеренной копипасты: сгенерированные таблицы и compatibility-шимы глушатся маркерами // CPD-OFF / // CPD-ON, как в PMD.
Форматы отчётов: text, json, PMD-совместимые xml/csv, markdown, sarif. Плюс два нестандартных. ai печатает по одной строке fileA:20-75 ~ fileB:18-70 на клон, без дублирования исходников: агент получает точные адреса, не сжигая контекст на повторяющийся код. shields отдаёт endpoint-JSON для бейджа с процентом дублирования в README.
CI-поведение как у PMD: exit code 4 при найденных дублях, --file-list, --skip-lexical-errors, --skip-duplicate-files: PMD-скрипты переезжают почти без правок. Сверх того .gitignore учитывается из коробки (причём не пост-фильтром, а обрезкой обхода: игнорируемые директории даже не читаются), а вместо простыни флагов можно закоммитить clone-alert.config.json.
Код: github.com/BaryshevRS/clone-alert, npm install --save-dev clone-alert.
npx clone-alert --minimum-tokens 50 --files src
Прогоните на своём проекте: как минимум узнаете, сколько у вас копий.
ссылка на оригинал статьи https://habr.com/ru/articles/1058364/