гейт судит 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:
+64
-14
@@ -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} файлах")
|
||||
|
||||
Reference in New Issue
Block a user