Перейти к содержанию

Правила дублирования кода

Дублирование кода -- один из самых распространённых источников технического долга. Когда одна и та же логика существует в нескольких местах, каждое исправление бага или добавление функциональности должно быть применено ко всем копиям -- и неизбежно какие-то копии будут пропущены. Эти правила обнаруживают структурно идентичные блоки кода по всей кодовой базе с помощью анализа потока токенов.


Дублирование кода (Code Duplication)

Идентификатор правила: duplication.clone

Что измеряет

Обнаруживает дублированные блоки кода между файлами с использованием хеширования потока токенов. Алгоритм работает в три этапа:

  1. Токенизация -- PHP-код разбирается в поток токенов.
  2. Нормализация -- токены трансформируются для игнорирования косметических различий: переменные заменяются на $_, строковые литералы на '_', числа на 0, пробелы и комментарии удаляются. Имена функций, методов и классов сохраняются, поэтому помечается только структурно идентичный код с разными именами переменных.
  3. Обнаружение -- дублированные последовательности находятся с помощью скользящего хеша (алгоритм Рабина-Карпа). Блоки короче минимальных пороговых значений игнорируются.

Представьте это как сравнение рецептов: если два рецепта содержат одни и те же шаги в том же порядке, но с разными названиями ингредиентов -- это дубликаты.

Пороговые значения

Величина Серьёзность Значение
< 50 покрытых строк кода Warning Заметное дублирование, стоит подумать об извлечении общей логики
>= 50 покрытых строк кода Error Значительное дублирование, рефакторинг настоятельно рекомендован

Положительные минимальные значения:

Опция По умолчанию Значение
min_lines 5 Минимум покрытых строк кода для допуска совпадающего блока
min_tokens 70 Минимум нормализованных токенов для допуска блока
error 50 Граница Error для каждой копии; ниже неё допущенная копия даёт Warning

Детектор допускает совпадающий блок по наибольшему числу покрытых строк кода среди его копий и по числу токенов. Затем он сообщает каждую допущенную копию с её собственным значением codeLines; комментарии, пустые строки и переводы строк не добавляют покрытый код. Серьёзность — Warning ниже error и Error на границе или выше неё. warning, threshold и локальные переопределения @qmx-threshold не поддерживаются.

Пример

Эти два метода имеют идентичную структуру, но разные имена переменных -- правило помечает их как дубликаты:

// В OrderService.php
public function calculateOrderTotal(Order $order): float
{
    $total = 0.0;
    foreach ($order->getItems() as $item) {
        $price = $item->getPrice();
        $quantity = $item->getQuantity();
        $subtotal = $price * $quantity;
        if ($item->hasDiscount()) {
            $subtotal *= (1 - $item->getDiscount());
        }
        $total += $subtotal;
    }
    return $total;
}

// В InvoiceService.php -- структурно идентичный
public function calculateInvoiceAmount(Invoice $invoice): float
{
    $amount = 0.0;
    foreach ($invoice->getLineItems() as $lineItem) {
        $rate = $lineItem->getPrice();
        $qty = $lineItem->getQuantity();
        $lineTotal = $rate * $qty;
        if ($lineItem->hasDiscount()) {
            $lineTotal *= (1 - $lineItem->getDiscount());
        }
        $amount += $lineTotal;
    }
    return $amount;
}

После нормализации оба метода дают одинаковую последовательность токенов -- переменные заменяются на $_, но имена методов/классов вроде getPrice, getQuantity, hasDiscount сохраняются.

Как исправить

  1. Извлечение метода (Extract Method) -- перенесите общую логику в общий метод или сервис:

    final class PriceCalculator
    {
        /** @param iterable<PriceableItem> $items */
        public function calculateTotal(iterable $items): float
        {
            $total = 0.0;
            foreach ($items as $item) {
                $subtotal = $item->getPrice() * $item->getQuantity();
                if ($item->hasDiscount()) {
                    $subtotal *= (1 - $item->getDiscount());
                }
                $total += $subtotal;
            }
            return $total;
        }
    }
    
  2. Паттерн Стратегия -- когда дублированные блоки отличаются в нескольких шагах, вынесите варьирующиеся части в объекты-стратегии.

  3. Шаблонный метод (Template Method) -- когда общая структура одинакова, но подклассы различаются в конкретных шагах, используйте абстрактный базовый класс с шаблонными методами.

Детали реализации

Qualimetrix использует алгоритм скользящего хеша Рабина-Карпа для эффективного обнаружения. Ключевой шаг -- нормализация, которая позволяет находить "почти-дубликаты", отличающиеся только именами переменных, строковыми значениями или числовыми константами. Этот подход аналогичен инструментам PMD CPD и Simian.

Поскольку имена функций/методов/классов сохраняются при нормализации, детектор не помечает два метода, вызывающих совершенно разные API, даже если их структура потока управления идентична.

Реализация принадлежит модулю Analysis.Evidence.Duplication, который объединяет обнаружение, результат одного запуска и правило. Оркестратор запуска вызывает модуль через узкий контракт reset/inspect; это изменение владения не меняет идентификатор правила, опции, алгоритм или вывод.

Отклонение от оригинальной спецификации

Полная нормализованная последовательность и число токенов определяют блок. Subject и symbolPath каждой находки указывают на файл копии относительно проекта; occurrence строится из хеша блока и порядка копии в файле. Номер строки в идентичность не входит. Перенос/переименование файла, изменение блока или порядка копий может изменить идентичность, поэтому fingerprints GitLab Code Quality и SARIF, а также baseline могут потребовать однократной перебазировки. Эта миграция идентичности не меняет схему baseline. Строки вне совпавших токенов сами по себе не меняют идентичность, пока детектор находит тот же блок.

Кандидат делится на связные сбалансированные сегменты токенов. Допущенные сегменты сохраняются; если ни один не проходит минимумы, используется весь match. Сегменты с одной полной нормализованной последовательностью объединяются в один блок перед вторым проходом покрытия: позиции файл/смещение различны, а порядковый номер копии в каждом файле считается один раз. Проверь затронутые ключи baseline и прими новые различимые идентичности после полного прогона. Свидетельства связных пар файлов сохраняются, а containment-свидетели, не добавляющие отдельного сообщаемого результата, удаляются. Это не общее правило удалять каждую вложенную копию. Некоторая кратность вложенных совпадений остаётся известным поведением; это не означает, что здесь устранены все визуально вложенные дубликаты.

Идентификаторы PHP keywords нормализуются без учёта регистра; обычные идентификаторы и существующий набор хешей для не-HTML токенов не меняются. Содержимое T_INLINE_HTML схлопывает ASCII whitespace и хешируется через xxh128. Строковые координаты токенов корректно считают CR, LF и CRLF, причём CRLF является одним переносом. Последний перенос в inline HTML не покрывает пустую следующую строку. Подсказка каждой копии извлекается из её байтового диапазона, начинается с первого содержательного фрагмента и ограничена 80 Unicode codepoints; при неверном UTF-8 применяется безопасный побайтовый fallback.

Инлайновый @qmx-ignore не подавляет этот канал

duplication.clone — канал уровня File; его нельзя адресовать через @qmx-ignore, @qmx-ignore-file или @qmx-ignore-next-line, явные селекторы получают отказ. У файлового субъекта нет namespace, поэтому глобальные и per-rule suppress_namespaces не подавляют эти находки. suppress_paths может подавить копии в перечисленных файлах. Отключи правило через disabled_rules: [duplication.clone] или --disable-rule=duplication.clone либо прими находки в baseline.

Константные массивы и массивы-свойства исключены всегда

Группа исключается, только когда каждая копия целиком лежит внутри объявления const или инициализатора статического/обычного свойства-массива. Группа, в которой данные совпали с исполняемым кодом, по-прежнему сообщается. Строки таблицы соответствий (например, 'key' => ['a' => ..., 'b' => ...], повторённые с разными значениями) после нормализации дают одинаковую последовательность токенов, но совет "вынести общий метод" не применим к таблице данных -- повторение одной и той же формы полей в разных строках и есть нормальная, правильная форма такой таблицы. Блок, пересекающий границу между объявлением данных и исполняемым кодом (или целиком лежащий в теле метода), по-прежнему сообщается. Это подавление безусловно и не может быть отключено.

Каждая допущенная копия имеет свою находку

Каждая допущенная копия сообщается со своим subject, числом покрытых строк кода и серьёзностью. Сообщение называет до десяти остальных копий и считает оставшиеся; связанные locations соответствуют названным копиям. Группа совпадения блока может измениться при изменении общей последовательности токенов или самих копий, поэтому правка может изменить находки в нетронутых файлах. Некоторая кратность вложенных совпадений остаётся известным поведением. Этот текст не утверждает, что удаляется каждый визуально вложенный дубликат.

Копии внутри одного файла

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

Интеграция с IDE

При использовании вывода в формате SARIF (--format=sarif) дублированные копии связываются через relatedLocations. Это означает, что копии дубликатов отображаются как кликабельные перекрёстные ссылки в VS Code (расширение SARIF Viewer) и JetBrains IDE, что упрощает переход от каждой копии к названным в ней остальным.

Превью содержимого

Находки дублирования включают превью содержимого дублированного блока. Это помогает быстро определить, какой именно код дублирован, без перехода к файлу:

Duplicated code block (16 code lines, 2 occurrences): "$total = 0.0; foreach ($order->getItems() as $item) { $price =..." — also at src/Service/InvoiceService.php:15-30

Каждое превью берётся из байтового диапазона именно той копии, для которой выпущена находка. Оно соединяет до трёх содержательных фрагментов из первых десяти строк, пропуская пустые строки и строки только со скобками, затем схлопывает пробелы. Длина ограничена 80 Unicode codepoints; неверный UTF-8 обрабатывается безопасным побайтовым fallback.

Конфигурация

# qmx.yaml
rules:
  duplication.clone:
    enabled: true
    min_lines: 5
    min_tokens: 70
    error: 50

Все три числовые опции должны быть положительными целыми. min_lines и min_tokens допускают блок; error задаёт серьёзность каждой копии.

# Увеличить минимум покрытых строк кода для допуска блока
bin/qmx check src/ --rule-opt="duplication.clone:min_lines=10"

# Увеличить минимальное число токенов
bin/qmx check src/ --rule-opt="duplication.clone:min_tokens=100"

# Задать границу Error для отдельной копии
bin/qmx check src/ --rule-opt="duplication.clone:error=60"

warning и threshold выведены из опций Duplication и отклоняются с кодом 3. @qmx-threshold duplication.clone отклоняется с annotation.unsupported-threshold, поскольку правило не поддерживает локальные переопределения порога.

Также можно полностью отключить правило:

bin/qmx check src/ --disable-rule=duplication.clone

Память и неполное чтение

Если детектор исчерпает память PHP, он выведет в stderr короткую подсказку с местом сбоя и текущим memory_limit, предложит --memory-limit или memory_limit в qmx.yaml и завершится с кодом 4. Фатальная нехватка памяти может прервать вывод отчёта, поэтому валидный или полный JSON не гарантируется. Поздний отказ чтения также очищает частичный результат Duplication и делает прогон неполным с кодом 4; пустой результат такого прогона не означает отсутствие копий.

Корректное окно и явное выключение

min_lines, min_tokens и error должны быть положительными целыми. Чтобы пропустить обнаружение, отключи duplication.clone в конфигурации или селекторе. Детектор использует подготовленный неизменяемый снимок опций и не обращается к исходной конфигурации повторно.