Различные статьи по Qt и c++

CI-проверка целостности справки: битые ссылки, несинхронизированные ключевые слова

Введение

Справка, как и код, подвержена «регрессиям» — рефакторинг переименовывает страницу, а ссылки на старое имя остаются; новая функция добавляется в код, но соответствующее ключевое слово в индексе справки не добавляется. Без автоматизированной проверки эти расхождения обнаруживаются только при ручном тестировании или, хуже, жалобами пользователей. В статье построим набор автоматизированных проверок целостности справки, запускаемых в CI при каждом изменении документации.

Концепция

Базовые проверки целостности справки делятся на несколько категорий: (1) все файлы, перечисленные в <files> .qhp, действительно существуют на диске; (2) все внутренние ссылки в HTML-страницах (<a href="...">) ведут на существующие файлы и существующие якоря (id) внутри них; (3) каждая страница, перечисленная в <files>, достижима хотя бы из одного узла TOC (нет «осиротевших» страниц, до которых нельзя добраться через навигацию); (4) опционально — каждое ключевое слово, упомянутое в коде приложения (через систему контекстной справки из статьи 164), действительно присутствует в индексе <keywords> .qhp.

Пример кода: CI-проверка целостности справки

#!/usr/bin/env python3
# check_help_integrity.py — проверка целостности проекта справки перед сборкой

import sys
import os
import re
from xml.etree import ElementTree as ET
from html.parser import HTMLParser

class LinkExtractor(HTMLParser):
    def __init__(self):
        super().__init__()
        self.links = []
        self.ids = set()

    def handle_starttag(self, tag, attrs):
        attr_dict = dict(attrs)
        if tag == "a" and "href" in attr_dict:
            self.links.append(attr_dict["href"])
        if "id" in attr_dict:
            self.ids.add(attr_dict["id"])

def check_files_exist(qhp_path, base_dir):
    errors = []
    tree = ET.parse(qhp_path)
    declared_files = {f.text for f in tree.findall(".//files/file")}

    for declared in declared_files:
        full_path = os.path.join(base_dir, declared)
        if not os.path.isfile(full_path):
            errors.append(f"Файл объявлен в .qhp, но отсутствует на диске: {declared}")

    toc_refs = {section.get("ref").split("#")[0] for section in tree.findall(".//section")}
    orphan_files = declared_files - toc_refs - {".css", ".png", ".jpg"}  # упрощённая фильтрация
    html_orphans = {f for f in orphan_files if f.endswith(".html")}
    for orphan in html_orphans:
        errors.append(f"Страница не достижима из TOC (orphan page): {orphan}")

    return errors, declared_files

def check_internal_links(declared_files, base_dir):
    errors = []
    page_ids = {}

    for filepath in declared_files:
        if not filepath.endswith(".html"):
            continue
        full_path = os.path.join(base_dir, filepath)
        if not os.path.isfile(full_path):
            continue
        with open(full_path, encoding="utf-8") as f:
            parser = LinkExtractor()
            parser.feed(f.read())
        page_ids[filepath] = parser.ids

        for link in parser.links:
            if link.startswith("http://") or link.startswith("https://"):
                continue  # внешние ссылки не проверяем здесь
            target_file, _, anchor = link.partition("#")
            resolved = target_file if target_file else filepath
            resolved_path = os.path.normpath(os.path.join(os.path.dirname(filepath), resolved))

            if resolved_path not in declared_files and target_file:
                errors.append(f"{filepath}: ссылка на несуществующий файл '{target_file}'")

    return errors

def main():
    qhp_path = sys.argv[1]
    base_dir = os.path.dirname(qhp_path)

    file_errors, declared_files = check_files_exist(qhp_path, base_dir)
    link_errors = check_internal_links(declared_files, base_dir)

    all_errors = file_errors + link_errors
    if all_errors:
        print(f"Найдено {len(all_errors)} проблем целостности справки:")
        for error in all_errors:
            print(f"  - {error}")
        sys.exit(1)

    print("Проверка целостности справки пройдена успешно")
    sys.exit(0)

if __name__ == "__main__":
    main()
# .github/workflows/check_help.yml — интеграция проверки в CI
name: Help Integrity Check

on:
  pull_request:
    paths:
      - 'docs/**'

jobs:
  check-help:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Проверка целостности справки
        run: python3 scripts/check_help_integrity.py docs/myapp.qhp

Пояснения к коду

check_files_exist() сначала сверяет список файлов, заявленных в <files> .qhp, с реально существующими на диске — это ловит ситуацию, когда файл был удалён или переименован, но запись в .qhp забыли обновить. Дополнительно вычисляются «осиротевшие» страницы — те, что перечислены в <files>, но ни на одну из них не ссылается ни один узел <section> в TOC, что означает, что пользователь не может добраться до этой страницы через обычную навигацию (она доступна только напрямую по URL или через индекс, если там тоже есть ссылка).

check_internal_links() разбирает каждую HTML-страницу через LinkExtractor, собирает все внутренние ссылки (<a href="...">) и проверяет, что целевой файл действительно входит в список заявленных файлов справки — внешние ссылки (http:///https://) явно исключаются из этой проверки, поскольку их доступность не относится к целостности самой справки. Workflow для github-actions-%d0%bf%d0%b5%d1%80%d0%b2%d1%8b%d0%b9-workflow-%d0%b4%d0%bb%d1%8f-%d1%81%d0%b1%d0%be%d1%80%d0%ba%d0%b8-qt-%d0%bf%d1%80%d0%be%d0%b5%d0%ba%d1%82%d0%b0/" class="qth-seo-autolink">GitHub Actions запускает проверку только при изменении файлов внутри docs/, что экономит время CI для pull request’ов, не затрагивающих документацию.

Подводные камни

  • Упрощённая фильтрация «осиротевших» файлов в примере ({".css", ".png", ".jpg"}) — не полноценное решение для всех типов нетекстовых ресурсов; в реальном проекте список расширений, не подлежащих проверке на достижимость через TOC (поскольку они являются вложенными ресурсами, а не самостоятельными страницами навигации), должен быть точнее настроен под структуру конкретного проекта справки.
  • Проверка якорей (id) не реализована в примере полностью — для строгой проверки нужно также сопоставлять каждую ссылку с конкретным якорем (#install) с фактически существующим id в целевом файле, не только с существованием самого файла; это требует двухпроходного анализа (сначала собрать все id со всех страниц, затем проверить все ссылки с якорями).
  • Ложные срабатывания на динамически генерируемых ссылках. Если часть ссылок формируется не статически в исходном HTML, а программно через JavaScript (для интерактивных справок на основе QWebEngineView), статический анализатор ссылок не сможет их обнаружить и проверить — для таких случаев нужен либо отдельный механизм валидации на этапе выполнения, либо явное соглашение о том, что динамические ссылки не покрываются автоматической проверкой.
  • Запуск проверки только при изменении docs/** может пропустить ситуацию, когда изменение кода приложения (переименование функции с контекстной справкой) рассинхронизирует ключевые слова в справке, но сам каталог docs/ физически не изменился — для полной защиты от этого класса рассинхронизации нужна отдельная, более широкая проверка, сопоставляющая ключевые слова контекстной справки в коде (статья 164) с индексом <keywords> в .qhp, независимо от того, что именно было изменено в pull request.