Репозиторий domains, services, skills, instructions Документация README, таблицы, ссылки, порты Аудит errors, warnings, suggestions Петля обратной связи commit → precommit-check → docs-audit → отчёт → правка → commit
Это и была задача. Текст в репозитории должен сверяться с файлами, а коммит должен получать быстрый ответ.
8 типов проверок в первой версии аудита
2 реальные дыры в README в первый запуск
1 скрипт на Bash без внешнего рантайма
2 режима быстрый локальный и полный с сетью

В моём репозитории живёт хаб игр epich.ru. Внутри лежат отдельные домены, сервисы, инструкции для агента, README-файлы, таблицы со скиллами, списки make-команд и куски документации про деплой. Когда такая база растёт, классическая проблема приходит быстро: каталог уже появился, compose-файл уже лежит на месте, а строка в README доедет позже. Позже обычно приходит через неделю. Иногда через месяц.

Меня интересовал один поворот этой истории. Я хотел получить skill, то есть скилл, набор инструкций для агента, который будет активно использоваться AI, то есть системой искусственного интеллекта. Значит, от него требовалась двойная дисциплина. С одной стороны, понятное описание правил. С другой стороны, скрипт, который сможет быстро доказать, что эти правила хоть как-то совпадают с реальным деревом файлов.

Идея вышла такой: сделать аудит документации частью обычного ритма разработки. Команда make docs-audit даёт развёрнутый отчёт. Хук pre-commit hook, то есть проверка перед коммитом, запускает быстрый режим. Ошибка даёт выход с кодом exit 1 и коммит останавливается. Предупреждение остаётся в логе, но поток работы сохраняется.

Сначала я сузил задачу, иначе проект утонул бы в деталях

Самый полезный ход здесь оказался совсем не техническим. Я начал с мини-опроса через askQuestions. Этот шаг нужен для проектирования границ. Аудит документации легко превращается в бесконечную машину подозрений: хочется проверять всё, сразу, до последней запятой. Такой путь обычно делает инструмент шумным и тяжёлым.

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

Что выбрал в первой версии.

В состав аудита вошли внутренние Markdown-ссылки, строчные якоря вида #L10, списки доменов в корневом README, таблица скиллов и инструкций, порты доменов, make-команды и ключевые переменные окружения. Внешние URL получили отдельный режим --full, потому что сеть внутри локального хука ломает ритм разработки.

flowchart TD
    A["Идея: аудит актуальности документации"] --> B["Опрос о границах"]
    B --> C["Что считать ошибкой"]
    B --> D["Что считать предупреждением"]
    B --> E["Что держать только в полном режиме"]

    C --> C1["Сломанная локальная ссылка"]
    C --> C2["Домен в docs, которого нет в репо"]
    C --> C3["Порт из compose отсутствует в README"]
    C --> C4["make-команда в README отсутствует в Makefile"]

    D --> D1["Скилл существует, но таблица о нём молчит"]
    D --> D2["Инструкция существует, но таблица о ней молчит"]
    D --> D3["env var в compose не описан в README"]

    E --> E1["Проверка внешних URL через curl"]
            
Первый важный разрез задачи. Ошибки блокируют коммит, предупреждения сохраняют сигнал, полный режим живёт отдельно.

Скилл как интерфейс к скрипту

Следом я оформил сам скилл. Для агента он играет роль короткой карты местности. В нём лежит имя, назначение, команда запуска и краткое описание того, что проверяется. Фокус тут совсем приземлённый: агент должен быстро понять, когда звать инструмент, а человек должен за минуту оценить рамки проверки.

---
name: docs-audit
description: "Проверка актуальности документации epich.ru: внутренние ссылки в .md файлах, строчные якоря (#L10), списки доменов, скиллов, инструкций, порты в README доменов, make-команды. Использовать перед ревью документации, после добавления нового домена, инструкции или скилла."
---

# Аудит документации epich.ru

## Команды

make docs-audit
make docs-audit ARGS=--full
make docs-audit ARGS=--fix
.github/skills/docs-audit/run_docs_audit.sh --full --fix

Такой формат дал сразу два выигрыша. Агенты получили одно место входа. Репозиторий получил короткую человеческую справку рядом с исполняемым скриптом. Когда инструментом пользуется и человек, и агент, именно этот симметричный слой обычно экономит больше всего времени.

Самый ценный блок в первой версии оказался самым простым по идее. Скрипт проходит по всем *.md файлам, вытаскивает Markdown-ссылки, отбрасывает сетевые адреса, якоря внутри текущей страницы и адреса почты, а затем проверяет, существует ли локальный путь. Отдельно обрабатываются строчные якоря вида path#L10. Если в файле меньше строк, это уже ошибка.

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

REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
cd "$REPO_ROOT" || exit 1

while IFS= read -r md_file; do
  md_dir="$(dirname "$md_file")"

  while IFS= read -r raw_link; do
    [[ -z "$raw_link" ]] && continue
    [[ "$raw_link" == http://* ]] && continue
    [[ "$raw_link" == https://* ]] && continue
    [[ "$raw_link" == mailto:* ]] && continue
    [[ "$raw_link" == "#"* ]] && continue

    local_path="${raw_link//%20/ }"
    fragment=""

    if [[ "$local_path" == *"#"* ]]; then
      fragment="#${local_path#*#}"
      local_path="${local_path%%#*}"
    fi

    [[ -z "$local_path" ]] && continue

    if [[ "$local_path" == /* ]]; then
      full_path="$REPO_ROOT${local_path}"
    else
      full_path="$md_dir/$local_path"
    fi

    if [[ ! -e "$full_path" ]]; then
      err "сломанная ссылка: $md_file → [$raw_link] → файл не найден"
      continue
    fi

    if [[ "$fragment" =~ ^#L([0-9]+)$ ]]; then
      linenr="${BASH_REMATCH[1]}"
      line_count=$(wc -l < "$full_path" 2>/dev/null || echo 0)
      if [[ "$linenr" -gt "$line_count" ]]; then
        err "якорь вне диапазона: $md_file → [$raw_link]"
      fi
    fi
  done < <(
    grep -oE '!?\[[^\]]*\]\([^)]+\)' "$md_file" 2>/dev/null |
    grep -oE '\([^)]+\)' |
    tr -d '()'
  )
done < <(find "$REPO_ROOT" -name '*.md' -not -path '*/.git/*')
Компромисс первой версии.

Для ссылок на заголовки я проверяю путь, а для якорей вида #L10 проверяю и путь, и диапазон строк. Такой выбор дал хороший баланс между скоростью, читаемостью кода и реальной пользой в рабочем цикле.

Подсистема номер два. Таблицы в README как зеркало дерева файлов

Когда репозиторий растёт, таблицы с доменами и скиллами устаревают быстрее, чем кажется в спокойный день. Здесь я решил действовать в обе стороны. С одной стороны, каждая директория из domains/*/ должна всплывать в таблице README. С другой стороны, каждая строка README, которая ссылается на домен, должна вести в реально существующую директорию.

Тот же подход я применил к списку скиллов и списку инструкций. Документация ссылается на name/SKILL.md, значит, каталог обязан лежать в .github/skills/. Документация ссылается на *.instructions.md, значит, файл обязан лежать в .github/instructions/. Обратная сторона тоже полезна: если каталог уже есть, а таблица про него молчит, аудит выдаёт предупреждение.

flowchart LR
    A["README.md"] --> B["Таблица доменов"]
    A --> C["Таблица скиллов"]
    A --> D["Список make-команд"]

    E["domains/*/"] --> B
    F[".github/skills/*/"] --> C
    G[".github/instructions/*.instructions.md"] --> C2["Таблица инструкций"]
    H["Makefile"] --> D

    B --> R1["Ошибка, если строка docs ведёт в пустоту"]
    E --> R2["Ошибка, если каталог выпал из таблицы"]
    F --> R3["Предупреждение, если каталог живёт без записи"]
    H --> R4["Ошибка, если команда описана, а цели нет"]
            
Здесь аудит сверяет живое дерево репозитория с тем, что обещает README и соседняя документация.
readme="$REPO_ROOT/README.md"

for domain_dir in "$REPO_ROOT"/domains/*/; do
  [[ -d "$domain_dir" ]] || continue
  domain_name=$(basename "${domain_dir%/}")

  if ! grep -qF "[$domain_name]" "$readme" 2>/dev/null; then
    err "домен '$domain_name' есть в domains/, но отсутствует в README.md"
    if [[ "$FIX" -eq 1 ]]; then
      suggest "добавить строку: | [$domain_name](https://$domain_name) | описание | [README](domains/$domain_name/README.md) |"
    fi
  fi
done

while IFS= read -r domain_ref; do
  [[ -z "$domain_ref" ]] && continue
  if [[ ! -d "$REPO_ROOT/$domain_ref" ]]; then
    err "README.md ссылается на $domain_ref/README.md, но директории нет"
  fi
done < <(
  grep -oE 'domains/[a-z0-9.-]+/README\.md' "$readme" |
  sed 's|/README\.md||' |
  sort -u
)

Здесь же всплыла первая реальная польза. Первый запуск аудита сразу дал два точных попадания. В репозитории существовали каталоги day.epich.ru и tutor.epich.ru, а в корневой таблице README их строк уже не хватало. То есть инструмент окупился буквально в момент рождения.

Промежуточный результат после первого запуска.

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

Подсистема номер три. Порты, make-команды и переменные окружения

После ссылок и таблиц я добавил проверки, которые ближе к инженерной поверхности проекта. Для каждого домена с compose.yml аудит ищет loadbalancer.server.port и сверяет его с README домена. Для корневого README он вытаскивает все упоминания make <cmd> и сверяет их с целями из Makefile. Для переменных окружения используется более мягкая логика: сигнал остаётся предупреждением.

Тут появляется важный принцип. В документации хватает мест, где полезен высокий уровень точности, и хватает мест, где жёсткий режим быстро породит шум. Порт сервиса относится к первой группе. Он конкретен, измерим и крайне полезен в README. Описание всех env vars относится ко второй группе. Там легко словить false positive, то есть ложное срабатывание, и начать бороться уже с самим инструментом.

Проверка Источник истины Уровень сигнала Причина выбора
Внутренние ссылки Файловая система Ошибка Сломанная ссылка почти всегда означает реальную дыру в docs
Домены в README domains/*/ Ошибка Корневая карта проекта должна совпадать с деревом каталогов
Скиллы и инструкции в таблицах .github/skills и .github/instructions Ошибка или предупреждение Ссылка в docs в пустоту критична, а молчание таблицы про новый каталог терпимо
Порты доменов compose.yml Ошибка Порт влияет на запуск, отладку и поддержку
Make-команды Makefile Ошибка README часто служит входной точкой для нового участника
env vars environment: в compose Предупреждение Тут цена ложного шума выше, чем цена мягкого сигнала
# Порты в README доменов
port=$(grep 'loadbalancer.server.port' "$domain_dir/compose.yml" 2>/dev/null |
  grep -oE '[0-9]{4,5}' | head -1)

if [[ -n "$port" ]] && ! grep -q "$port" "$domain_dir/README.md" 2>/dev/null; then
  err "$domain_name: порт $port из compose.yml отсутствует в README.md"
fi

# Make-команды в корневом README
readme_make_cmds=$(
  grep -oE '`make [a-zA-Z0-9_-]+' "$readme" |
  sed 's/`make //' |
  sort -u
)

makefile_targets=$(
  grep -oE '^\.PHONY: .+' "$REPO_ROOT/Makefile" |
  sed 's/\.PHONY: //' |
  tr ' ' '\n' |
  sort -u
)

Полный режим через сеть и быстрый режим для хука

Внешние URL я тоже хотел контролировать, но тут есть известная ловушка. Сетевой запрос внутри локального цикла разработки делает инструмент нервным. Домашний интернет проседает, сервис отвечает медленно, человек теряет ритм, а после пары таких случаев команду хочется выключить. Поэтому проверка внешних адресов отправилась в отдельный режим --full.

В полном режиме скрипт проходит по всем Markdown-файлам, вытаскивает сетевые адреса, убирает дубликаты и делает проверку через curl. Ответы из диапазона 2xx и редиректы считаются нормальным исходом. Проблемы сети идут как предупреждение. Ошибочный HTTP-код превращается в полноценную ошибку.

if [[ "$FULL" -eq 1 ]]; then
  declare -A seen_urls

  while IFS= read -r md_file; do
    while IFS= read -r url; do
      [[ -z "$url" ]] && continue
      [[ -n "${seen_urls[$url]:-}" ]] && continue
      seen_urls["$url"]=1

      status=$(curl -sL --max-time 10 -o /dev/null -w "%{http_code}" "$url" 2>/dev/null || echo "000")

      case "$status" in
        2*|301|302|307|308) : ;;
        000) warn "сетевая ошибка: $url" ;;
        *)   err "HTTP $status: $url" ;;
      esac
    done < <(grep -oE 'https?://[a-zA-Z0-9./_?=&%#:@-]+' "$md_file" | sort -u)
  done < <(find "$REPO_ROOT" -name '*.md' -not -path '*/.git/*')
fi
Почему сеть живёт отдельно.

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

Подсистема номер четыре. Встраивание в precommit-check

После того как скрипт показал первые полезные результаты, его осталось воткнуть в реальный поток. Здесь я выбрал самый прямой путь. В Makefile появилась цель docs-audit. В precommit-check.sh появилась отдельная секция, которая зовёт скрипт в быстром режиме и поднимает счётчик ошибок, если аудит вернул плохой код выхода.

Мне нравится такой формат по одной причине. Разработчик видит ровно ту же команду, которую позже увидит и агент, и CI. То есть локальная отладка, командная привычка и автоматический сценарий живут вокруг одного исполняемого файла, а это сильно снижает цену поддержки.

sequenceDiagram
    participant Dev as Разработчик
    participant Git as Git hook
    participant PC as precommit-check.sh
    participant DA as docs-audit
    participant Repo as Репозиторий

    Dev->>Git: git commit
    Git->>PC: запуск проверок
    PC->>DA: bash run_docs_audit.sh
    DA->>Repo: чтение README, Makefile, compose, markdown
    Repo-->>DA: данные файлов
    DA-->>PC: exit 0 или exit 1
    PC-->>Git: итог хука
    Git-->>Dev: коммит проходит или останавливается
            
Интеграция вышла короткой. Важен именно общий путь запуска, а не магия внутри хука.
docs-audit:
	@bash .github/skills/docs-audit/run_docs_audit.sh $(ARGS)

# Секция в precommit-check.sh
if [[ -f "$REPO_ROOT/.github/skills/docs-audit/run_docs_audit.sh" ]]; then
  if ! bash "$REPO_ROOT/.github/skills/docs-audit/run_docs_audit.sh" 2>&1; then
    fail=$((fail + 1))
  fi
fi

Забавный баг нашёлся почти сразу

Первая версия скрипта выглядела уже рабочей, но на реальном прогоне всплыл характерный сюжет. Шаблоны поиска ссылок и команд сперва учитывали только буквы, дефисы и подчёркивания. В репозитории же жили e2e-testing и команда make e2e. Цифры в паттерне отсутствовали. Значит, аудит давал искажение.

Такие моменты я люблю больше всего. Они быстро показывают, что инструмент уже приносит пользу, но пока ещё дышит сырой инженерной жизнью. Поправка оказалась короткой: регулярные выражения получили диапазон 0-9, после чего шум ушёл, а реальные ошибки остались. Это и есть хороший знак для первой версии.

# Было слишком узко
grep -oE '`[a-zA-Z_-]+/SKILL\.md`'

# Стало шире и ближе к реальности
grep -oE '`[a-zA-Z0-9_-]+/SKILL\.md`'

# Аналогичный ход для команд из README
grep -oE '`make [a-zA-Z0-9_-]+'

Что дало решение уже в первой итерации

Самый интересный эффект тут вовсе не в том, что Bash-скрипт умеет проверять ссылки. Самый интересный эффект в другом. Документация перестаёт быть набором добрых намерений и становится проверяемым слоем проекта. Когда новый домен добавлен в дерево каталогов, но выпал из корневой таблицы, аудит ловит это сразу. Когда в README осталась make-команда без цели в Makefile, аудит ловит это сразу. Когда порт сервиса уехал из compose, а README сохранил старую цифру, аудит ловит это сразу.

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

Итог первой версии.

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

Что я бы добавил дальше

В такой системе всегда есть запас для роста. Я бы двигался по ступеням, без резких скачков. Сначала добавил бы отдельную проверку для README-ссылок из copilot-instructions.md. Потом аккуратно улучшил бы валидацию якорей заголовков. Дальше уже можно смотреть в сторону автоматической генерации фрагментов таблиц или мягкого режима с готовым patch-предложением.

Есть и ещё одна ветка, которая выглядит многообещающе. Сейчас security scan в моём проекте умеет выводить находки и писать задачи в TODO. Мне ближе более сдержанный режим: сканер печатает ограниченный список находок, скажем до ста записей, а дальше даёт понятную приписку, что общий объём выше. Это сохраняет обзорность и убирает лишнее давление на рабочие файлы. Для docs-audit такой принцип тоже подходит: сигнал должен быть сильным, а интерфейс отчёта должен оставаться спокойным.

Финальная мысль

Эта история началась с ощущения, что документация в большом репозитории часто живёт на честном слове. Завершилась она маленьким, но очень полезным контрактом между кодом и текстом. Мне нравится именно такой масштаб решения. Один скилл. Один Bash-скрипт. Несколько точных правил. И очень быстрый путь от ошибки к правке.

Если у вас в проекте есть корневой README, таблицы со скиллами, каталогами или сервисами, и эти списки регулярно расходятся с реальным деревом файлов, такой подход легко перенести к себе. Самая большая польза обычно появляется уже после первого запуска. Иногда вместе с лёгким раздражением. Обычно это хороший знак.