Как добавить правило¶
Добавление правила — это один новый файл в src/nk/rules/ и один каталог
в tests/fixtures/. Ничего больше править не нужно: реестр находит правило
по декоратору, тесты — по имени каталога.
Рецепт¶
- Найти проверяемое требование в стандартах и выписать его пункт в каждом, где оно записано: правило объявляет карту «стандарт — пункт», а не один номер.
- Придумать идентификатор — мнемонику из строчных букв и дефисов, например
figure-caption-dot. Ни стандарта, ни пункта в идентификаторе нет: одно требование бывает записано в нескольких стандартах под разными пунктами, и правило на все них одно. Если мнемоника подходит и рисунку, и таблице, различайте объектом:figure-caption-dot,table-caption-dot. -
Создать файл в каталоге категории — того, что правило регулирует. Каталог и есть категория: она задаёт раздел в документации, и объявлять её в декораторе не нужно. Правило, положенное мимо категории, реестр не примет.
Каталог Категория 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и включается профилем. Это про шум, а не про источник: выключенными по умолчанию бывают правила любого происхождения. -
Создать
tests/fixtures/<id>/bad.texиtests/fixtures/<id>/good.tex. - Написать докстринг правила — из него собирается страница документации.
- Прогнать
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:
Точный кусок исходника под регионом даёт doc.slice(region) — им удобно строить
замену «то же самое, но без чего-то»:
Правку объявляют, только когда замена однозначна. «Перенести подпись ниже
рисунка», «добавить ссылку в текст», «заменить «рис.» на «рисунок»» правками не
являются: в первых двух нужно решение о структуре, в третьем — согласование
падежа. Такие правила ограничиваются 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}
Сопоставление строгое в обе стороны: множество строк с находками должно совпадать с множеством помеченных строк. Лишняя находка и пропущенная пометка одинаково роняют тест.
Если конструкция занимает несколько строк, пометка ставится на ту строку, куда указывает находка, — то есть на начало команды:
Комментарий вырезается парсером и на разбор не влияет.
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"])
Профиль переопределяет параметр так:
Переименование правила¶
Идентификатор написан в чужих профилях, в директивах % 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.
Проверки¶
Прогоняет ruff check, ruff format --check, ty check и pytest. Всё должно
проходить полностью. Отдельно:
Покрытие тестами считает make coverage: сводка с непокрытыми строками выводится
в терминал, подробный отчёт ложится в htmlcov/index.html. В make test и в CI
покрытие не измеряется, порога нет: проект в разработке.
Что проверяет тест автоматически¶
- правило сработало на
bad.texровно на помеченных строках; - правило не сработало на
good.texни разу; - у каждой находки непустые
message,requirementиcontext; - пункт находки совпадает с объявленным в декораторе для стандарта по умолчанию, а источник назван, если пункт есть;
suggestionесть, если правило не объявлено сallow_missing_suggestion=True;- у каждого правила есть каталог фикстур, у каждого каталога — правило.
Последние два пункта не дают репозиторию расползтись, поэтому обойти их нельзя.
После добавления правила¶
Перегенерируйте страницы документации:
Каталог 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 попадает в находку и в документацию; перечень
стандартов в документации собирается из этого объявления и руками не пишется.
Дальше — по требованиям:
- Правилам, требование которых записано и в новом стандарте, дописать его
пункт в
standards. Правило без пункта нового стандарта под ним просто не запускается — это и есть отбор требований, отдельного списка нет. Общих правил и требований положений это не касается: они работают под любым стандартом. - Требование, которого в прежних стандартах не было, оформляется обычным правилом с картой из одного стандарта.
- Положить встроенный профиль
src/nk/profiles/<имя>.tomlсstandard = "<id>"и наследованием отbase— по образцуgost-r-2.105.toml. В нём же переоцениваются уровни и отключается то, чего стандарт не требует. - Добавить профиль в таблицу готовых в профилях
и прогнать
make check: встроенные профили проверяются тестами вtests/test_builtin_profiles.py.
Чего в правилах не делаем¶
- Не вызываем LLM: инструмент детерминирован.
- Не читаем PDF: источник данных — только исходники
.tex. Проверки, требующие скомпилированного документа (поля, гарнитуры, кегль, колонцифры), в область видимости не входят. - Не цитируем стандарты ни в коде, ни в тестах, ни в документации. Допустимы только номера пунктов и собственные краткие формулировки.
- Не правим файлы отчёта наугад. Правка объявляется только там, где замена
однозначна; во всех остальных случаях правило ограничивается
suggestion.