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

Как добавить правило

Добавление правила — это один новый файл в src/nk/rules/ и один каталог в tests/fixtures/. Ничего больше править не нужно: реестр находит правило по декоратору, тесты — по имени каталога.

Рецепт

  1. Найти проверяемое требование в стандартах и выписать его пункт в каждом, где оно записано: правило объявляет карту «стандарт — пункт», а не один номер.
  2. Придумать идентификатор — мнемонику из строчных букв и дефисов, например figure-caption-dot. Ни стандарта, ни пункта в идентификаторе нет: одно требование бывает записано в нескольких стандартах под разными пунктами, и правило на все них одно. Если мнемоника подходит и рисунку, и таблице, различайте объектом: figure-caption-dot, table-caption-dot.
  3. Создать файл в каталоге категории — того, что правило регулирует. Каталог и есть категория: она задаёт раздел в документации, и объявлять её в декораторе не нужно. Правило, положенное мимо категории, реестр не примет.

    Каталог Категория
    rules/structure/ Структура отчёта
    rules/headings/ Заголовки и рубрикация
    rules/enumerations/ Перечисления
    rules/abstract/ Реферат
    rules/terms/ Термины и сокращения
    rules/figures/ Иллюстрации
    rules/tables/ Таблицы
    rules/formulas/ Формулы
    rules/notes/ Примечания и сноски
    rules/bibliography/ Источники и ссылки на них
    rules/appendices/ Приложения
    rules/text/ Изложение текста
    rules/typography/ Типографика

    Категории не повторяют разделы стандарта: у разных стандартов разделы разные, а иллюстрации остаются иллюстрациями. Перечень и порядок — в src/nk/core/categories.py.

    Заодно решить, чьё это требование, — ключом origin:

    origin Кто требует Пункты
    Origin.STANDARD стандарт; по умолчанию обязательны
    Origin.UNIVERSAL никто, но нужно всем: типографика, набор необязательны
    Origin.REGULATION положение вуза, методические указания кафедры запрещены

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

    Отдельно от происхождения правило, дающее много находок или спорное, объявляется default_off=True и включается профилем. Это про шум, а не про источник: выключенными по умолчанию бывают правила любого происхождения.

  4. Создать tests/fixtures/<id>/bad.tex и tests/fixtures/<id>/good.tex.

  5. Написать докстринг правила — из него собирается страница документации.
  6. Прогнать make check и uv run nk rules docs.

Полный пример

src/nk/rules/figures/figure_caption_dot.py:

"""Точка в конце наименования рисунка."""

from collections.abc import Iterable

from nk.core.document import Document
from nk.core.finding import Finding, Severity
from nk.core.rule import rule
from nk.core.standards import G732
from nk.rules._shared import FIGURE_ENVIRONMENTS, caption_text, captions, one_line


@rule(
    id="figure-caption-dot",
    standards={G732: "6.5.7"},
    severity=Severity.ERROR,
    title="Наименование рисунка заканчивается точкой",
)
def figure_caption_dot(doc: Document) -> Iterable[Finding]:
    r"""Проверяет наименование рисунка на точку в конце. Точка внутри
    наименования нарушением не является.

    ## Почему это нарушение

    Наименование рисунка приводят с прописной буквы без точки в конце.

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

    Убрать точку в конце `\caption`.
    """
    for environment in doc.structure.find_environments(*FIGURE_ENVIRONMENTS):
        for command in captions(environment):
            text = caption_text(command)
            if not text.endswith("."):
                continue
            yield figure_caption_dot.finding(
                doc,
                command.span,
                message="Наименование рисунка заканчивается точкой.",
                requirement="Наименование рисунка приводят с прописной буквы без точки в конце.",
                suggestion=render_caption(command, one_line(text[:-1])),
                col=command.col,
            )

Разбор по частям:

  • Декоратор объявляет метаданные один раз. id, standards, severity и title больше нигде повторять не нужно. standards — карта «стандарт — пункт»: одно требование бывает записано в нескольких стандартах, а какой из пунктов попадёт в находку, решает профиль. Карта говорит, где требование записано, origin — чьё оно: требование стандарта работает только под стандартами из карты, типографика и требование положения — под любым.
  • Докстринг — источник страницы правила в документации, см. ниже.
  • Сигнатура фиксирована протоколом Rule: правило принимает Document и возвращает Iterable[Finding]. Соответствие протоколу проверяет тест в tests/test_rule.py, а не типизатор.
  • Правило — чистая функция. Никакого ввода-вывода, никакого состояния между вызовами, никакого чтения файлов помимо того, что уже в Document.
  • figure_caption_dot.finding(...) — обращение правила к самому себе по имени. Оно разрешается в момент вызова, когда декоратор уже отработал. Метод сам проставляет rule_id, severity, пункт и источник требования с учётом профиля, а также excerpt и context.
  • Позиция. Первым позиционным аргументом идёт Line или Span; col указывается отдельно, если известна колонка.

Что видит правило

Document хранит два представления одного исходника.

Построчное — для проверок, где важен текст строки:

for line in doc.iter_lines():
    line.raw  # исходная строка
    line.stripped  # без комментария (с учётом экранированного \% и аргумента \verb)
    line.lineno  # номер строки, с единицы
    line.path  # файл, а не файл-агрегатор

Структурное — для проверок, где важна вложенность:

doc.structure.find_environments("figure", "table")  # окружения по именам
doc.structure.find_commands("caption", "label")  # команды по именам
doc.structure.enclosing(path, lineno)  # самое внутреннее окружение
environment.children  # вложенные окружения
environment.commands  # команды непосредственно внутри
environment.all_commands()  # включая вложенные окружения
doc.structure.covered_lines("tabular")  # строки внутри окружений, один раз на документ
doc.ordered_commands()  # все команды в порядке отчёта, один раз на документ

Команды внутри аргументов других команд сканер видит наравне с остальными: \caption{Схема\label{fig:a}} даёт и caption, и label, а \ref в сноске или в \textbf — обычную ссылку. Тела определений макросов (\newcommand, \def, \newenvironment) не сканируются.

Готовые помощники — в src/nk/rules/_shared.py: наборы имён окружений, извлечение текста подписи, метки и ссылки, наименования структурных элементов. Прежде чем писать свой разбор, посмотрите, нет ли там нужного.

Структурные элементы

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

from nk.core.elements import ABSTRACT_ROLE
from nk.rules._shared import structural_headings

for command, element in structural_headings(doc):
    if element not in doc.profile.elements.role(ABSTRACT_ROLE):
        continue

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

Наименование из nk.core.elements (ABSTRACT, CONTENTS и прочие) — значение по умолчанию для словаря и для параметров правила. Сравнивать с ним элемент документа не надо: под профилем кафедры или другого стандарта элемент называется иначе.

Описание правила

Докстринг функции правила — источник его страницы в документации. Он пишется на Markdown и попадает на страницу как есть, поэтому заголовки в нём — второго уровня.

def figure_caption_dot(doc: Document) -> Iterable[Finding]:
    """Проверяет наименование рисунка на точку в конце. Точка внутри
    наименования нарушением не является.

    ## Почему это нарушение

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

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

    Убрать точку в конце `\caption`.
    """
  • Первый абзац — что правило ищет и чего не трогает. Сюда же выносятся известные ограничения: случаи, в которых по исходникам решить нельзя.
  • «Почему это нарушение» — смысл требования своими словами. Не «так написано в стандарте», а что ломается, если сделать иначе.
  • «Как исправить» — что сделать в исходнике.

Если в докстринге есть обратная косая — а она есть, как только появляется пример LaTeX, — объявляйте его сырым: r""".

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

% DOC-BEGIN
\begin{figure}
    \includegraphics{img/setup.png}
    \caption{Схема экспериментальной установки}
\end{figure}
% DOC-END

Правка для --fix

Если исправление однозначно, правило может объявить автоматическую правку — тогда nk check --fix применит её сам. Частый случай: suggestion и есть готовая замена, тогда достаточно указать заменяемый регион:

yield figure_caption_dot.finding(
    doc,
    command.span,
    message="Наименование рисунка заканчивается точкой.",
    requirement="Наименование рисунка приводят с прописной буквы без точки в конце.",
    suggestion=render_caption(command, one_line(text[:-1])),
    col=command.col,
    fix=command.region,  # заменить команду целиком текстом suggestion
)

render_caption собирает команду подписи с новым текстом, сохраняя метку внутри подписи, краткую форму и тип объекта у \captionof; caption_text отдаёт текст наименования уже без \label. command.region — точные границы команды вместе с аргументами, включая многострочные. Правило с правками объявляется с fixable=True; тест сверяет объявление с действительностью, поэтому соврать не получится.

Когда заменяемый текст отличается от подсказки — например подсказка говорит «убрать двоеточие», а заменить нужно один символ на пустую строку, — передаётся готовый Fix:

fix = Fix(Region.in_line(line.path, line.lineno, colon, colon + 1), "")

Точный кусок исходника под регионом даёт doc.slice(region) — им удобно строить замену «то же самое, но без чего-то»:

fix = Fix(command.region, doc.slice(command.region).replace("\\-", ""))

Правку объявляют, только когда замена однозначна. «Перенести подпись ниже рисунка», «добавить ссылку в текст», «заменить «рис.» на «рисунок»» правками не являются: в первых двух нужно решение о структуре, в третьем — согласование падежа. Такие правила ограничиваются suggestion.

Тест на фикстурах проверяет правки сам: применяет их к bad.tex в несколько проходов и требует, чтобы находки с правками исчезли и новых не появилось.

Требования к тексту находки

Это самая частая точка деградации, поэтому требование проверяется на ревью. Находка должна быть самодостаточна: по ней нарушение исправляется руками или передачей вывода --format agent в Claude Code, без дополнительных пояснений. Читающий не обязан открывать ГОСТ или страницу правила.

  • message — что не так, одной фразой, про конкретное место. Плохо: Нарушение п. 6.5.7. Хорошо: Наименование рисунка заканчивается точкой.
  • requirement — что требуется, своими словами. Цитировать стандарт нельзя: стандарты не являются общественным достоянием, и их текста в репозитории нет. Плохо: См. п. 6.5.7. Хорошо: Наименование рисунка приводят с прописной буквы без точки в конце.
  • suggestion — конкретное действие или готовый фрагмент на замену. Плохо: Исправьте оформление. Хорошо: \caption{Схема экспериментальной установки}.

suggestion обязателен. Если исправление принципиально неоднозначно, объявите правило с allow_missing_suggestion=True — тест это проверяет.

Фикстуры

bad.tex — файл с нарушениями. Каждая строка, где правило обязано сработать, помечается комментарием % EXPECT:

\begin{figure}
    \includegraphics{img/setup.png}
    \caption{Схема экспериментальной установки.} % EXPECT
\end{figure}

Сопоставление строгое в обе стороны: множество строк с находками должно совпадать с множеством помеченных строк. Лишняя находка и пропущенная пометка одинаково роняют тест.

Если конструкция занимает несколько строк, пометка ставится на ту строку, куда указывает находка, — то есть на начало команды:

\caption{Зависимость погрешности от числа % EXPECT
измерений.}

Комментарий вырезается парсером и на разбор не влияет.

good.tex — файл, на котором правило не должно сработать ни разу. Это не формальность: именно сюда складываются пограничные случаи, на которых легко получить ложное срабатывание. Хороший good.tex содержит конструкции, похожие на нарушение, но нарушением не являющиеся — точку внутри наименования, подпись таблицы там, где правило проверяет рисунки, многозначное число в начале заголовка.

Оба файла обязаны разбираться парсером без замечаний.

Параметры

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

@rule(
    id="keywords-count",
    standards={G732: "5.3.2.1"},
    severity=Severity.ERROR,
    title="Число ключевых слов вне допустимого диапазона",
    params={"keywords_min": 5, "keywords_max": 15},
)
def keywords_count(doc: Document) -> Iterable[Finding]:
    params = keywords_count.params(doc)
    lower = int(params["keywords_min"])

Профиль переопределяет параметр так:

[rules."keywords-count".params]
keywords_max = 20

Переименование правила

Идентификатор написан в чужих профилях, в директивах % nk: ignore внутри чужих отчётов и в снимках. Поэтому прежнее имя объявляется рядом с новым:

@rule(
    id="figure-caption-dot",
    standards={G732: "6.5.7"},
    severity=Severity.ERROR,
    title="Наименование рисунка заканчивается точкой",
    deprecated_ids=("G732-6.5.7-caption-dot",),
)

Прежнее имя продолжает работать в --select, --ignore, в профиле и в директивах подавления. Занять именем, под которым уже живёт другое правило, не получится: реестр такое объявление отвергает.

Снимок --baseline прежние имена не разворачивает: находки из него перестанут скрываться, и снимок надо перезаписать nk check --write-baseline.

Проверки

make check

Прогоняет ruff check, ruff format --check, ty check и pytest. Всё должно проходить полностью. Отдельно:

make lint
make test
make format

Покрытие тестами считает make coverage: сводка с непокрытыми строками выводится в терминал, подробный отчёт ложится в htmlcov/index.html. В make test и в CI покрытие не измеряется, порога нет: проект в разработке.

Что проверяет тест автоматически

  • правило сработало на bad.tex ровно на помеченных строках;
  • правило не сработало на good.tex ни разу;
  • у каждой находки непустые message, requirement и context;
  • пункт находки совпадает с объявленным в декораторе для стандарта по умолчанию, а источник назван, если пункт есть;
  • suggestion есть, если правило не объявлено с allow_missing_suggestion=True;
  • у каждого правила есть каталог фикстур, у каждого каталога — правило.

Последние два пункта не дают репозиторию расползтись, поэтому обойти их нельзя.

После добавления правила

Перегенерируйте страницы документации:

uv run nk rules docs

Каталог docs/rules/ руками не редактируется: он собирается из реестра, а тест проверяет, что записанное на диске совпадает со сгенерированным.

Новый стандарт

Стандарты перечислены в src/nk/core/standards.py. Новый добавляется объявлением рядом с остальными:

GR7_0_97 = Standard("GR7097", "ГОСТ Р 7.0.97-2016")

STANDARDS: dict[str, Standard] = {item.id: item for item in (G732, GR2105, GR7_0_97)}

id — то, чем стандарт называют профиль (standard = "GR7097") и правило в карте standards. title попадает в находку и в документацию; перечень стандартов в документации собирается из этого объявления и руками не пишется.

Дальше — по требованиям:

  1. Правилам, требование которых записано и в новом стандарте, дописать его пункт в standards. Правило без пункта нового стандарта под ним просто не запускается — это и есть отбор требований, отдельного списка нет. Общих правил и требований положений это не касается: они работают под любым стандартом.
  2. Требование, которого в прежних стандартах не было, оформляется обычным правилом с картой из одного стандарта.
  3. Положить встроенный профиль src/nk/profiles/<имя>.toml с standard = "<id>" и наследованием от base — по образцу gost-r-2.105.toml. В нём же переоцениваются уровни и отключается то, чего стандарт не требует.
  4. Добавить профиль в таблицу готовых в профилях и прогнать make check: встроенные профили проверяются тестами в tests/test_builtin_profiles.py.

Чего в правилах не делаем

  • Не вызываем LLM: инструмент детерминирован.
  • Не читаем PDF: источник данных — только исходники .tex. Проверки, требующие скомпилированного документа (поля, гарнитуры, кегль, колонцифры), в область видимости не входят.
  • Не цитируем стандарты ни в коде, ни в тестах, ни в документации. Допустимы только номера пунктов и собственные краткие формулировки.
  • Не правим файлы отчёта наугад. Правка объявляется только там, где замена однозначна; во всех остальных случаях правило ограничивается suggestion.