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