гейт судит staged-файлы; рендер диаграмм пошёл параллельно

Гейт проверял рабочее дерево целиком — то есть не то, что уедет в историю, а
то, что лежит на диске рядом. Плюс платил за это временем: пятнадцать секунд на
каждый коммит с правкой markdown, потому что одиннадцать блоков рендерились по
очереди, каждый своим запуском mermaid-cli со своим chromium.

diagrams.py научился двум вещам. Первая — принимать файлы списком: без
аргументов обходит репозиторий как раньше, с аргументами смотрит только
названные, отбирая из них markdown внутри корня (гейт передаёт весь staged, где
есть и скрипты, и удалённое). Вторая — рендерить пулом потоков: работа целиком в
ожидании подпроцесса, своего интерпретатора ей не надо, а потолок в восемь
воркеров упирается в память chromium, а не в двадцать четыре ядра. Порядок
находок берётся из порядка сбора, не из порядка ответов, так что вывод
детерминирован. Весь репозиторий — 3 секунды вместо 15, один файл — 1.

В хуке теперь {staged_files} у диаграмм, ruff и pyrefly. Два исключения
остались, и оба по существу: copies.py сверяет копию с домом, а дом лежит в
другом файле, которого в индексе может не быть — список staged дал бы «копии
дословны» ровно там, где правка дома их и разошлась; frontmatter.py обходит всё
за сотые доли секунды, экономить нечего. Оба объяснены прямо у своих задач.

ruff встал с --fix и stage_fixed: безопасное чинится само и доносится до этого
же коммита. Иначе исправленный файл оставался бы в рабочем дереве, а в историю
уезжал бы невычищенный — гейт зелёный, коммит грязный.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
av
2026-08-07 09:30:56 +03:00
co-authored by Claude Opus 5
parent 5067bc2048
commit 61cd9fcd37
3 changed files with 114 additions and 51 deletions
+30 -22
View File
@@ -312,13 +312,19 @@ uv run python scripts/copies.py # 0 сошлось, 1 расхождение
правдоподобно, диff показывает разумную строку, а рендер падает. правдоподобно, диff показывает разумную строку, а рендер падает.
``` ```
uv run python scripts/diagrams.py # 0 рендерятся, 1 нет, 3 нет mermaid-cli uv run python scripts/diagrams.py # весь репозиторий
uv run python scripts/diagrams.py A.md B.md # только названные файлы
# 0 рендерятся, 1 нет, 3 нет mermaid-cli
``` ```
Рендерит `mmdc` с PATH или `npx --yes @mermaid-js/mermaid-cli`; ни того ни Рендерит `mmdc` с PATH или `npx --yes @mermaid-js/mermaid-cli`; ни того ни
другого нет — код 3, а не молчаливый успех. Прогон занимает секунды на блок — другого нет — код 3, а не молчаливый успех. Это самая дорогая проверка
это самая дорогая из трёх проверок, и в гейте коммита она стоит **под glob по репозитория: каждый блок — отдельный запуск mermaid-cli со своим chromium,
markdown**: правка одних скриптов проходит мгновенно. секунда с лишним. Поэтому у неё два рычага, и оба нужны гейту коммита: **блоки
собираются все сразу, а рендерятся параллельно** (пул потоков, порядок вывода
берётся из порядка сбора), и **проверять можно названные файлы, а не весь
репозиторий**. Весь репозиторий — три секунды вместо пятнадцати, один
файл — одна.
Чего проверка **не** ловит — расхождение диаграммы с прозой вокруг неё. Дословного Чего проверка **не** ловит — расхождение диаграммы с прозой вокруг неё. Дословного
соответствия между текстом и графом нет, сличать нечего, и держится это соответствия между текстом и графом нет, сличать нечего, и держится это
@@ -336,25 +342,27 @@ lefthook install # пишет .git/hooks/pre-commit
lefthook run pre-commit # прогнать руками, не коммитя lefthook run pre-commit # прогнать руками, не коммитя
``` ```
| Проверка | Когда идёт | Сколько | | Проверка | Когда идёт | Что смотрит | Сколько |
| --- | --- | --- | | --- | --- | --- | --- |
| фронтматтеры | правка `*.md` | миллисекунды | | фронтматтеры | правка `*.md` | весь репозиторий | миллисекунды |
| копии правил | правка `*.md` | миллисекунды | | копии правил | правка `*.md` | весь репозиторий | миллисекунды |
| диаграммы | правка `*.md` | ~15 с на весь репозиторий | | диаграммы | правка `*.md` | staged-файлы | ~1 с на файл |
| `ruff check .` | правка `*.py` | доли секунды | | `ruff check --fix` | правка `*.py` | staged-файлы | доли секунды |
| `pyrefly check` | правка `*.py` | доли секунды | | `pyrefly check` | правка `*.py` | staged-файлы | доли секунды |
Glob разводит две половины: коммит, трогающий одни скрипты, не платит за рендер Glob разводит две половины: коммит, трогающий одни скрипты, не платит за рендер
диаграмм, а коммит в документы не гоняет линтеры. Внутри своей половины диаграмм, а коммит в документы не гоняет линтеры.
проверяется **весь репозиторий**, а не изменённые файлы: и расхождение копии, и
находка ruff в соседнем файле — это ровно тот случай, когда правка сломала не
себя.
Два свойства, о которых стоит знать заранее: **Судятся staged-файлы, а не рабочее дерево** — гейт обязан проверять то, что
уедет в историю, а не то, что случайно лежит рядом на диске. Исключений два, и
оба про существо, а не про удобство: `copies.py` сверяет копию с домом, а дом
лежит в другом файле, которого в индексе может не быть (список staged дал бы
«копии дословны» ровно там, где правка дома их и разошлась), а `frontmatter.py`
обходит весь репозиторий за сотые доли секунды — экономить тут нечего.
- **судится рабочее дерево, а не индекс.** Скрипты обходят репозиторий целиком **`ruff` чинит безопасное сам, и починка доносится до этого же коммита**
и про `git add` не знают: частичный коммит при грязном дереве проверяется по (`stage_fixed: true`). Иначе исправленный файл остался бы в рабочем дереве, а в
тому, что на диске. Это цена того, что проверки — обход, а не фильтр файлов, и историю уехал бы невычищенный — худший из исходов: гейт зелёный, коммит грязный.
она принята: расхождение копий и битая диаграмма ловятся именно обходом;
- **обход разовый — `LEFTHOOK=0 git commit …`.** Он законен ровно для случая, **Обход разовый — `LEFTHOOK=0 git commit …`.** Он законен ровно для случая,
когда найденное нечем чинить прямо сейчас; молча пропущенная проверка — нет. когда найденное нечем чинить прямо сейчас; молча пропущенная проверка — нет.
+20 -15
View File
@@ -7,9 +7,10 @@
# без которого tasks.py и docs.py перестают работать в чужом проекте. # без которого tasks.py и docs.py перестают работать в чужом проекте.
# Всё остальное (проза, устройство, язык) — предмет ревью, а не хука. # Всё остальное (проза, устройство, язык) — предмет ревью, а не хука.
# #
# Скрипты читают **рабочее дерево целиком**, а не индекс: частичный коммит при # **Что судится — staged-файлы, а не рабочее дерево**, всюду, где проверка
# грязном дереве судится по тому, что на диске. Это осознанно — они и написаны # умеет смотреть поимённо: гейт обязан судить то, что уедет в историю, а не то,
# как обход репозитория, а не как фильтр по файлам. # что случайно лежит на диске рядом. Два исключения названы у своих задач, и оба
# — про то, что проверке нужен весь репозиторий по существу, а не для удобства.
# #
# Ставится `lefthook install` (см. README, «Гейт коммита»). Обойти разово — # Ставится `lefthook install` (см. README, «Гейт коммита»). Обойти разово —
# `LEFTHOOK=0 git commit …`; обход законен ровно для того случая, когда чинить # `LEFTHOOK=0 git commit …`; обход законен ровно для того случая, когда чинить
@@ -18,6 +19,11 @@
pre-commit: pre-commit:
parallel: true parallel: true
jobs: jobs:
# Обе проверки документов идут по всему репозиторию, и это не недосмотр.
# copies.py сверяет копию с домом, а дом лежит в другом файле, которого в
# индексе может не быть: список staged дал бы «копии дословны» там, где
# правка дома их и разошлась. frontmatter.py смотрел бы поимённо, но весь
# обход стоит сотые доли секунды — платить за него нечем.
- name: фронтматтеры - name: фронтматтеры
glob: "*.md" glob: "*.md"
run: python3 scripts/frontmatter.py run: python3 scripts/frontmatter.py
@@ -26,23 +32,22 @@ pre-commit:
glob: "*.md" glob: "*.md"
run: python3 scripts/copies.py run: python3 scripts/copies.py
# Секунды, а не миллисекунды: рендер идёт настоящим mermaid-cli. Поэтому # Самая дорогая проверка: каждый блок — свой запуск mermaid-cli со своим
# glob — правка одних только скриптов проходит гейт мгновенно. Нет ни # chromium. Отсюда и staged-файлы вместо обхода, и параллель внутри самого
# `mmdc`, ни `npx` — код 3, коммит отказан: «не проверено» здесь не то же # скрипта: репозиторий целиком — 3 секунды, один файл — одна.
# самое, что «проверено и сошлось».
- name: диаграммы - name: диаграммы
glob: "*.md" glob: "*.md"
run: python3 scripts/diagrams.py run: python3 scripts/diagrams.py {staged_files}
# Скрипты — под своим glob и через uv: версии линтеров прибиты точно, и # Линтеры — через uv: версии прибиты точно, и `uv run` берёт именно их, а не
# `uv run` берёт именно их, а не то, что оказалось в PATH. Обе проверки # то, что оказалось в PATH. `--fix` чинит безопасное сам, `stage_fixed`
# укладываются в доли секунды на весь репозиторий, поэтому проверяется он # доносит починку до этого же коммита — иначе она осталась бы в рабочем
# целиком, а не изменённые файлы: находка в чужом файле здесь означает, что # дереве, а в историю уехал бы невычищенный файл.
# правка сломала соседа.
- name: ruff - name: ruff
glob: "*.py" glob: "*.py"
run: uv run ruff check . stage_fixed: true
run: uv run ruff check --fix {staged_files}
- name: pyrefly - name: pyrefly
glob: "*.py" glob: "*.py"
run: uv run pyrefly check run: uv run pyrefly check {staged_files}
+64 -14
View File
@@ -29,6 +29,18 @@ Chromium запускается с `--no-sandbox`: на современных
«No usable sandbox». Содержимое здесь своё и локальное, так что песочница ничего «No usable sandbox». Содержимое здесь своё и локальное, так что песочница ничего
не защищает — она только мешает запуску. не защищает — она только мешает запуску.
Дорого здесь не чтение markdown, а рендер: каждый блок — отдельный запуск
mermaid-cli со своим chromium, секунда с лишним. Отсюда два рычага, и оба нужны
гейту коммита:
- **блоки собираются все сразу, а рендерятся параллельно.** Сбор — обход файлов,
он же и определяет порядок вывода; рендер ждёт подпроцесс и потому пускается
пулом потоков. Порядок находок от этого не плывёт: он берётся из порядка
сбора, а не из порядка ответов;
- **проверять можно не весь репозиторий, а названные файлы.** `diagrams.py
путь.md …` смотрит только их — так гейт платит за диаграммы ровно того файла,
который правят. Без аргументов обходится весь репозиторий, как и раньше.
Коды выхода — тот же словарь, что у tasks.py, docs.py и copies.py: Коды выхода — тот же словарь, что у tasks.py, docs.py и copies.py:
0 все диаграммы рендерятся 0 все диаграммы рендерятся
1 диаграмма не рендерится 1 диаграмма не рендерится
@@ -41,15 +53,22 @@ from __future__ import annotations
import argparse import argparse
import json import json
import os
import re import re
import shutil import shutil
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path from pathlib import Path
OK, DRIFT, USAGE, ENV, INTERNAL = 0, 1, 2, 3, 4 OK, DRIFT, USAGE, ENV, INTERNAL = 0, 1, 2, 3, 4
# Потолок параллели. Каждый рендер — свой chromium, а он стоит сотни мегабайт:
# на машине с 24 ядрами упереться в память дешевле, чем в процессор. Восемь
# снимают почти весь выигрыш и не рискуют ничем.
MAX_WORKERS = 8
FENCE_OPEN = re.compile(r"^\s*```mermaid\s*$") FENCE_OPEN = re.compile(r"^\s*```mermaid\s*$")
FENCE_CLOSE = re.compile(r"^\s*```\s*$") FENCE_CLOSE = re.compile(r"^\s*```\s*$")
@@ -72,10 +91,28 @@ class Block:
self.where = f"{path.relative_to(root).as_posix()}:{line}" self.where = f"{path.relative_to(root).as_posix()}:{line}"
def collect(root: Path) -> list[Block]: def markdown(root: Path, named: list[Path]) -> list[Path]:
"""Все mermaid-блоки репозитория, в порядке обхода.""" """Какие файлы смотреть: названные или весь репозиторий.
Названные фильтруются теми же правилами, что и обход: только `*.md`, только
внутри корня, без пропускаемых каталогов. Гейт передаёт сюда staged-файлы
списком, в котором есть и скрипты, и удалённое, — отбор его дело, а не
вызывающего.
"""
if not named:
return sorted(root.rglob("*.md"))
out = []
for path in named:
full = (path if path.is_absolute() else root / path).resolve()
if full.suffix == ".md" and full.is_file() and full.is_relative_to(root):
out.append(full)
return sorted(set(out))
def collect(root: Path, named: list[Path]) -> list[Block]:
"""Все mermaid-блоки, в порядке обхода. Порядок вывода берётся отсюда."""
found: list[Block] = [] found: list[Block] = []
for path in sorted(root.rglob("*.md")): for path in markdown(root, named):
if any(part in SKIP for part in path.relative_to(root).parts): if any(part in SKIP for part in path.relative_to(root).parts):
continue continue
lines = path.read_text(encoding="utf-8").splitlines() lines = path.read_text(encoding="utf-8").splitlines()
@@ -106,11 +143,17 @@ def renderer() -> list[str] | None:
return None return None
def render(cmd: list[str], block: Block, workdir: Path, config: Path) -> str | None: def render(cmd: list[str], block: Block, workdir: Path, config: Path,
"""Отрендерить блок. None — получилось, иначе сообщение об ошибке.""" slot: int) -> str | None:
src = workdir / "d.mmd" """Отрендерить блок. None — получилось, иначе сообщение об ошибке.
`slot` разводит временные файлы: рендеры идут параллельно, и одно имя на
всех означало бы, что блоки затирают исходники друг друга — с находками,
которые не воспроизводятся поодиночке.
"""
src = workdir / f"d{slot}.mmd"
src.write_text(block.text, encoding="utf-8") src.write_text(block.text, encoding="utf-8")
out = workdir / "d.svg" out = workdir / f"d{slot}.svg"
done = subprocess.run( done = subprocess.run(
[*cmd, "-p", str(config), "-i", str(src), "-o", str(out)], [*cmd, "-p", str(config), "-i", str(src), "-o", str(out)],
capture_output=True, capture_output=True,
@@ -132,6 +175,8 @@ def render(cmd: list[str], block: Block, workdir: Path, config: Path) -> str | N
def main() -> int: def main() -> int:
ap = argparse.ArgumentParser(description="Проверка mermaid-диаграмм.") ap = argparse.ArgumentParser(description="Проверка mermaid-диаграмм.")
ap.add_argument("paths", nargs="*", type=Path,
help="какие файлы смотреть; без них — весь репозиторий")
ap.add_argument("--dir", default=".", help="корень репозитория") ap.add_argument("--dir", default=".", help="корень репозитория")
args = ap.parse_args() args = ap.parse_args()
@@ -141,9 +186,10 @@ def main() -> int:
f" (нет .claude-plugin)", file=sys.stderr) f" (нет .claude-plugin)", file=sys.stderr)
return ENV return ENV
blocks = collect(root) blocks = collect(root, args.paths)
if not blocks: if not blocks:
print("диаграмм нет") print("диаграмм нет" if not args.paths
else "диаграмм нет в названных файлах")
return OK return OK
cmd = renderer() cmd = renderer()
@@ -153,15 +199,19 @@ def main() -> int:
" или запусти проверку там, где есть npx.", file=sys.stderr) " или запусти проверку там, где есть npx.", file=sys.stderr)
return ENV return ENV
broken: list[tuple[Block, str]] = []
with tempfile.TemporaryDirectory(prefix="diagrams-") as tmp: with tempfile.TemporaryDirectory(prefix="diagrams-") as tmp:
workdir = Path(tmp) workdir = Path(tmp)
config = workdir / "puppeteer.json" config = workdir / "puppeteer.json"
config.write_text(json.dumps({"args": ["--no-sandbox"]}), encoding="utf-8") config.write_text(json.dumps({"args": ["--no-sandbox"]}), encoding="utf-8")
for block in blocks: workers = max(1, min(MAX_WORKERS, len(blocks), os.cpu_count() or 1))
error = render(cmd, block, workdir, config) with ThreadPoolExecutor(max_workers=workers) as pool:
if error is not None: # Потоки, а не процессы: работа целиком в ожидании подпроцесса,
broken.append((block, error)) # своего интерпретатора ей не надо. `map` сохраняет порядок блоков,
# поэтому вывод не зависит от того, кто ответил первым.
errors = list(pool.map(
lambda pair: render(cmd, pair[1], workdir, config, pair[0]),
enumerate(blocks)))
broken = [(b, e) for b, e in zip(blocks, errors, strict=True) if e is not None]
files = len({b.path for b in blocks}) files = len({b.path for b in blocks})
print(f"диаграмм {len(blocks)} в {files} файлах") print(f"диаграмм {len(blocks)} в {files} файлах")