гейт судит 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
+64 -14
View File
@@ -29,6 +29,18 @@ Chromium запускается с `--no-sandbox`: на современных
«No usable sandbox». Содержимое здесь своё и локальное, так что песочница ничего
не защищает — она только мешает запуску.
Дорого здесь не чтение markdown, а рендер: каждый блок — отдельный запуск
mermaid-cli со своим chromium, секунда с лишним. Отсюда два рычага, и оба нужны
гейту коммита:
- **блоки собираются все сразу, а рендерятся параллельно.** Сбор — обход файлов,
он же и определяет порядок вывода; рендер ждёт подпроцесс и потому пускается
пулом потоков. Порядок находок от этого не плывёт: он берётся из порядка
сбора, а не из порядка ответов;
- **проверять можно не весь репозиторий, а названные файлы.** `diagrams.py
путь.md …` смотрит только их — так гейт платит за диаграммы ровно того файла,
который правят. Без аргументов обходится весь репозиторий, как и раньше.
Коды выхода — тот же словарь, что у tasks.py, docs.py и copies.py:
0 все диаграммы рендерятся
1 диаграмма не рендерится
@@ -41,15 +53,22 @@ from __future__ import annotations
import argparse
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
OK, DRIFT, USAGE, ENV, INTERNAL = 0, 1, 2, 3, 4
# Потолок параллели. Каждый рендер — свой chromium, а он стоит сотни мегабайт:
# на машине с 24 ядрами упереться в память дешевле, чем в процессор. Восемь
# снимают почти весь выигрыш и не рискуют ничем.
MAX_WORKERS = 8
FENCE_OPEN = re.compile(r"^\s*```mermaid\s*$")
FENCE_CLOSE = re.compile(r"^\s*```\s*$")
@@ -72,10 +91,28 @@ class Block:
self.where = f"{path.relative_to(root).as_posix()}:{line}"
def collect(root: Path) -> list[Block]:
"""Все mermaid-блоки репозитория, в порядке обхода."""
def markdown(root: Path, named: list[Path]) -> list[Path]:
"""Какие файлы смотреть: названные или весь репозиторий.
Названные фильтруются теми же правилами, что и обход: только `*.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] = []
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):
continue
lines = path.read_text(encoding="utf-8").splitlines()
@@ -106,11 +143,17 @@ def renderer() -> list[str] | None:
return None
def render(cmd: list[str], block: Block, workdir: Path, config: Path) -> str | None:
"""Отрендерить блок. None — получилось, иначе сообщение об ошибке."""
src = workdir / "d.mmd"
def render(cmd: list[str], block: Block, workdir: Path, config: Path,
slot: int) -> str | None:
"""Отрендерить блок. None — получилось, иначе сообщение об ошибке.
`slot` разводит временные файлы: рендеры идут параллельно, и одно имя на
всех означало бы, что блоки затирают исходники друг друга — с находками,
которые не воспроизводятся поодиночке.
"""
src = workdir / f"d{slot}.mmd"
src.write_text(block.text, encoding="utf-8")
out = workdir / "d.svg"
out = workdir / f"d{slot}.svg"
done = subprocess.run(
[*cmd, "-p", str(config), "-i", str(src), "-o", str(out)],
capture_output=True,
@@ -132,6 +175,8 @@ def render(cmd: list[str], block: Block, workdir: Path, config: Path) -> str | N
def main() -> int:
ap = argparse.ArgumentParser(description="Проверка mermaid-диаграмм.")
ap.add_argument("paths", nargs="*", type=Path,
help="какие файлы смотреть; без них — весь репозиторий")
ap.add_argument("--dir", default=".", help="корень репозитория")
args = ap.parse_args()
@@ -141,9 +186,10 @@ def main() -> int:
f" (нет .claude-plugin)", file=sys.stderr)
return ENV
blocks = collect(root)
blocks = collect(root, args.paths)
if not blocks:
print("диаграмм нет")
print("диаграмм нет" if not args.paths
else "диаграмм нет в названных файлах")
return OK
cmd = renderer()
@@ -153,15 +199,19 @@ def main() -> int:
" или запусти проверку там, где есть npx.", file=sys.stderr)
return ENV
broken: list[tuple[Block, str]] = []
with tempfile.TemporaryDirectory(prefix="diagrams-") as tmp:
workdir = Path(tmp)
config = workdir / "puppeteer.json"
config.write_text(json.dumps({"args": ["--no-sandbox"]}), encoding="utf-8")
for block in blocks:
error = render(cmd, block, workdir, config)
if error is not None:
broken.append((block, error))
workers = max(1, min(MAX_WORKERS, len(blocks), os.cpu_count() or 1))
with ThreadPoolExecutor(max_workers=workers) as pool:
# Потоки, а не процессы: работа целиком в ожидании подпроцесса,
# своего интерпретатора ей не надо. `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})
print(f"диаграмм {len(blocks)} в {files} файлах")