Единственный нормальный детектор копипасты требовал JVM. Поэтому я написал свой

от автора

Копипаста относится к самым незаметным и живучим видам технического долга. Стиль проверяет линтер, типы проверяет 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:

  1. Каждый файл превращается в поток лексических токенов.

  2. По окнам фиксированной длины (--minimum-tokens, по умолчанию 50) считается rolling hash Карпа-Рабина.

  3. Окна с одинаковым хешем группируются, портированный из 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/