У меня есть ощущение, что мы входим в довольно странный этап разработки.
Код стало писать заметно дешевле. Большие команды и раньше могли случайно размножать похожие куски логики в разных частях монорепы. Но теперь к этому добавились AI-агенты: они быстро делают задачи, быстро создают новые файлы, быстро копируют удачные паттерны. Это удобно. Но у удобства есть побочный эффект: повторяющийся код тоже стало создавать дешевле.
И проблема тут не в том, что «AI пишет плохой код». Люди тоже копипастят. Проблема в скорости потока. Когда кода становится больше, ревьюеру сложнее руками заметить, что где-то уже есть почти такой же блок, только с другим именем функции и двумя измененными условиями.
Для этого давно существуют детекторы copy-paste. Один из популярных инструментов — jscpd. Он проходит по проекту, ищет похожие фрагменты, строит отчеты и может завалить CI, если процент дублей выше порога.
Но у такого инструмента есть практическая ловушка: если quality gate работает медленно, его начинают запускать реже.
Сначала «только ночью». Потом «только перед релизом». Потом «ну мы потом почистим». А потом дубли становятся частью архитектуры.
Я решил попробовать сделать быстрый нативный клон jscpd на Rust: jscpd-rs. Цель простая: оставить знакомый workflow jscpd, но сделать проверку достаточно дешевой, чтобы ее не жалко было гонять на каждый pull request.
Что такое jscpd, если вы не пользовались
jscpd — это детектор copy-paste в исходниках. Он ищет одинаковые или очень похожие фрагменты в разных файлах, выводит список найденных клонов и умеет генерировать отчеты для людей и CI.
Типичный сценарий:
jscpd --threshold 5 --exitCode 1 src
Если дублей больше 5%, команда завершится с кодом 1, и CI сможет остановить pull request.
jscpd-rs старается поддерживать тот же базовый сценарий:
-
CLI-команды
jscpd,jscpd-rsиjscpd-server; -
конфиги
.jscpd.jsonиpackage.json#jscpd; -
флаги для
threshold,min-lines,min-tokens,ignore,format,reporters,output; -
отчеты
console,json,sarif,html,xml,csv,markdown,badge,xcode,ai; -
падение CI при превышении порога;
-
установку через npm или Cargo.
Быстрый старт через npm:
npx jscpd-rs --threshold 5 --exitCode 1 .
Или через Cargo:
cargo install jscpd-rs --lockedjscpd --threshold 5 --exitCode 1 .
Через npm на Linux, macOS и Windows ставятся prebuilt-бинарники. Rust toolchain на машине пользователя в обычном случае не нужен.
Почему я не стал делать «новый умный анализатор»
Очень хотелось уйти в сторону «а давайте еще AST, call graph, семантическое сравнение, LLM-подсказки, авто-рефакторинг». Но это быстро превращает маленький инструмент в исследовательский проект.
Мне хотелось другого: взять понятный существующий workflow и ускорить его.
То есть не «мы придумали новый способ понимать код», а:
-
найти файлы;
-
разбить код на токены;
-
посчитать компактные отпечатки;
-
найти совпадающие окна;
-
выдать отчет;
-
завалить CI, если порог превышен.
Это скучная архитектура. И в данном случае это плюс.
Внутри jscpd-rs основной путь примерно такой:
-
Rust discovery для файлов, ignore/glob/gitignore;
-
Oxc для JS/TS/JSX/TSX токенизации;
-
нативная токенизация для длинного хвоста форматов;
-
параллельная подготовка и поиск;
-
числовые хэши вместо тяжелых строковых сравнений;
-
нативные репортеры без запуска JavaScript runtime.
Важно: это не обертка над оригинальным jscpd и не вызов Node.js из Rust. Я сознательно не хотел тащить JS runtime в hot path, потому что иначе главный выигрыш быстро растворяется в интеграционном слое.
Про совместимость: не 1:1 любой ценой
С клонами инструментов есть неприятный выбор.
Можно пытаться сделать идеальное 1:1 совпадение всех пар дублей, порядка вывода и внутренних счетчиков. Это красиво, но может съесть огромное количество времени, особенно на multi-way clones, где разные реализации могут выбрать разные пары, покрывая одни и те же строки.
Я выбрал другой первый gate: coverage-first compatibility.
На одинаковом вводе и одинаковых опциях jscpd-rs не должен пропускать дублированные строки, которые нашел upstream jscpd. Если Rust-версия нашла больше — это видно в compatibility report как extra, и с этим можно разбираться отдельно. Но главный блокер — не пропустить то, что нашел upstream.
Для CI это практичнее. Опасная ошибка — когда инструмент молчит, хотя дубли есть. А не когда он выбрал другую пару для одного и того же кластера.
Оригинальный jscpd лежит в репозитории как git submodule и используется как исполняемая спецификация. В release gate запускаются обе версии, отчеты сравниваются, а несовпадения разбираются как compatibility work.
Цифры
На текущем публичном benchmark-наборе получилось так:
|
Репозиторий |
Формат |
jscpd-rs avg |
upstream jscpd avg |
Ускорение |
|---|---|---|---|---|
|
React |
JavaScript |
0.197325s |
10.413453s |
52.77x |
|
Next.js |
TypeScript |
0.270786s |
14.983243s |
55.33x |
|
Prometheus |
Go |
0.083162s |
4.842499s |
58.23x |
Это не обещание, что на любом проекте будет ровно так же. Это публичный baseline, который нужен для двух вещей:
-
показать порядок ускорения на реальных популярных репозиториях;
-
не дать будущим правкам незаметно убить производительность.
В release gate сейчас есть performance threshold: если скорость проседает ниже заданного уровня, релиз должен остановиться. Это важно, потому что для этого проекта скорость — не приятный бонус, а основная причина существования.
Запустить проверку можно из репозитория:
scripts/release-candidate.sh
Как это выглядит в CI
Минимальный GitHub Actions workflow:
name: duplicate-codeon: pull_request: push: branches: [main]jobs: jscpd: runs-on: ubuntu-latest permissions: contents: read security-events: write steps: - uses: actions/checkout@v5 - uses: actions/setup-node@v5 with: node-version: 22 - name: Run duplicate-code check run: | npx --yes jscpd-rs \ --threshold 5 \ --exitCode 1 \ --reporters console,json,sarif \ --output report \ --ignore "node_modules/**" \ --ignore "dist/**" \ --ignore "coverage/**" \ --ignore "target/**" \ . - name: Upload SARIF if: always() uses: github/codeql-action/upload-sarif@v3 with: sarif_file: report/jscpd-sarif.json
Если Code Scanning не нужен, можно убрать security-events: write и шаг загрузки SARIF.
Для реального проекта я бы вынес настройки в .jscpd.json:
{ "minLines": 5, "minTokens": 50, "threshold": 5, "reporters": ["console", "json", "sarif"], "output": "report", "ignore": [ "node_modules/**", "dist/**", "coverage/**", "target/**", ".next/**", "generated/**", "**/*.snap" ], "gitignore": true, "noTips": true}
Тогда в CI останется короткая команда:
npx --yes jscpd-rs .
Что с форматами
У upstream jscpd большой список поддерживаемых форматов. В jscpd-rs registry синхронизирован с upstream: сейчас это 223 формата и 206 extension mappings.
Но тут есть важная оговорка.
Для JS/TS/JSX/TSX используется Oxc. Для остальных форматов на первом этапе работает generic native tokenization плюс небольшие специализированные блоки там, где это было нужно для compatibility gates: Markdown, markup, Vue, Svelte, Astro, Apex, TAP.
Я не пытаюсь сразу написать идеальный парсер для каждого языка. Это был бы отличный способ утонуть. Логика такая: если формат нужен и generic tokenization дает пропуски относительно upstream — поднимаем его приоритет и улучшаем.
Что пока не сделано
Чтобы не создавать ложное ощущение «полный 1:1 уже готов», ограничения лучше сказать прямо.
В текущей 0.x линии:
-
динамические npm reporters/stores/listeners/plugins не загружаются;
-
HTML-отчет практически совместим, но не pixel-perfect;
-
точные token totals и порядок clone pairs могут отличаться;
-
это Rust crate и CLI, а не JavaScript API clone;
-
long-tail форматы будут подтягиваться по реальным кейсам и compatibility reports.
Для меня это нормальный компромисс. Если пытаться закрыть сразу весь surface upstream jscpd, можно очень долго не выпустить ничего полезного.
Где это может быть полезно
Я вижу три основных сценария.
Первый — большие репозитории и монорепы, где duplicate-code check полезен, но его неприятно держать в каждом PR из-за времени выполнения.
Второй — команды, которые активно используют AI-агентов. Агент может быстро написать рабочий код, но ему полезно дать быстрый детерминированный feedback loop: «вот этот кусок уже есть, не размножай».
Третий — обычный hygiene gate для проектов, где не хочется превращать поиск дублей в отдельный ручной процесс.
Команда для первого запуска:
npx jscpd-rs --threshold 5 --exitCode 1 .
Если отчет шумный, я бы сначала не спорил с инструментом, а почистил ignore list: generated files, snapshots, build output, vendored code. Потом уже выбирал threshold.
Что хочется получить от сообщества
Сейчас самый полезный feedback — реальные репозитории и реальные несовпадения.
Особенно интересны:
-
проекты, где
jscpd-rsпропускает дубли, найденные upstreamjscpd; -
слишком шумные extra findings;
-
проблемы установки через npm prebuilt binaries;
-
форматы, где generic tokenization ведет себя плохо;
-
публичные benchmark-кейсы, похожие на настоящие монорепы.
Ссылки:
-
crates.io: https://crates.io/crates/jscpd-rs
-
docs.rs: https://docs.rs/jscpd-rs
Если коротко: я не пытаюсь заменить идею jscpd. Наоборот, мне нравится workflow. Я хочу, чтобы такой check был достаточно быстрым и дешевым, чтобы его не выключали в CI.
Потому что когда код начинают писать быстрее — проверки тоже должны становиться быстрее.
ссылка на оригинал статьи https://habr.com/ru/articles/1044548/