diff --git a/README.md b/README.md index 6af5df7..1ed7c45 100644 --- a/README.md +++ b/README.md @@ -312,13 +312,19 @@ uv run python scripts/copies.py # 0 сошлось, 1 расхождение правдоподобно, ди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`; ни того ни -другого нет — код 3, а не молчаливый успех. Прогон занимает секунды на блок — -это самая дорогая из трёх проверок, и в гейте коммита она стоит **под glob по -markdown**: правка одних скриптов проходит мгновенно. +другого нет — код 3, а не молчаливый успех. Это самая дорогая проверка +репозитория: каждый блок — отдельный запуск mermaid-cli со своим chromium, +секунда с лишним. Поэтому у неё два рычага, и оба нужны гейту коммита: **блоки +собираются все сразу, а рендерятся параллельно** (пул потоков, порядок вывода +берётся из порядка сбора), и **проверять можно названные файлы, а не весь +репозиторий**. Весь репозиторий — три секунды вместо пятнадцати, один +файл — одна. Чего проверка **не** ловит — расхождение диаграммы с прозой вокруг неё. Дословного соответствия между текстом и графом нет, сличать нечего, и держится это @@ -336,25 +342,27 @@ lefthook install # пишет .git/hooks/pre-commit lefthook run pre-commit # прогнать руками, не коммитя ``` -| Проверка | Когда идёт | Сколько | -| --- | --- | --- | -| фронтматтеры | правка `*.md` | миллисекунды | -| копии правил | правка `*.md` | миллисекунды | -| диаграммы | правка `*.md` | ~15 с на весь репозиторий | -| `ruff check .` | правка `*.py` | доли секунды | -| `pyrefly check` | правка `*.py` | доли секунды | +| Проверка | Когда идёт | Что смотрит | Сколько | +| --- | --- | --- | --- | +| фронтматтеры | правка `*.md` | весь репозиторий | миллисекунды | +| копии правил | правка `*.md` | весь репозиторий | миллисекунды | +| диаграммы | правка `*.md` | staged-файлы | ~1 с на файл | +| `ruff check --fix` | правка `*.py` | staged-файлы | доли секунды | +| `pyrefly check` | правка `*.py` | staged-файлы | доли секунды | Glob разводит две половины: коммит, трогающий одни скрипты, не платит за рендер -диаграмм, а коммит в документы не гоняет линтеры. Внутри своей половины -проверяется **весь репозиторий**, а не изменённые файлы: и расхождение копии, и -находка ruff в соседнем файле — это ровно тот случай, когда правка сломала не -себя. +диаграмм, а коммит в документы не гоняет линтеры. -Два свойства, о которых стоит знать заранее: +**Судятся staged-файлы, а не рабочее дерево** — гейт обязан проверять то, что +уедет в историю, а не то, что случайно лежит рядом на диске. Исключений два, и +оба про существо, а не про удобство: `copies.py` сверяет копию с домом, а дом +лежит в другом файле, которого в индексе может не быть (список staged дал бы +«копии дословны» ровно там, где правка дома их и разошлась), а `frontmatter.py` +обходит весь репозиторий за сотые доли секунды — экономить тут нечего. -- **судится рабочее дерево, а не индекс.** Скрипты обходят репозиторий целиком - и про `git add` не знают: частичный коммит при грязном дереве проверяется по - тому, что на диске. Это цена того, что проверки — обход, а не фильтр файлов, и - она принята: расхождение копий и битая диаграмма ловятся именно обходом; -- **обход разовый — `LEFTHOOK=0 git commit …`.** Он законен ровно для случая, - когда найденное нечем чинить прямо сейчас; молча пропущенная проверка — нет. +**`ruff` чинит безопасное сам, и починка доносится до этого же коммита** +(`stage_fixed: true`). Иначе исправленный файл остался бы в рабочем дереве, а в +историю уехал бы невычищенный — худший из исходов: гейт зелёный, коммит грязный. + +**Обход разовый — `LEFTHOOK=0 git commit …`.** Он законен ровно для случая, +когда найденное нечем чинить прямо сейчас; молча пропущенная проверка — нет. diff --git a/lefthook.yml b/lefthook.yml index 07d63f7..c674451 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -7,9 +7,10 @@ # без которого tasks.py и docs.py перестают работать в чужом проекте. # Всё остальное (проза, устройство, язык) — предмет ревью, а не хука. # -# Скрипты читают **рабочее дерево целиком**, а не индекс: частичный коммит при -# грязном дереве судится по тому, что на диске. Это осознанно — они и написаны -# как обход репозитория, а не как фильтр по файлам. +# **Что судится — staged-файлы, а не рабочее дерево**, всюду, где проверка +# умеет смотреть поимённо: гейт обязан судить то, что уедет в историю, а не то, +# что случайно лежит на диске рядом. Два исключения названы у своих задач, и оба +# — про то, что проверке нужен весь репозиторий по существу, а не для удобства. # # Ставится `lefthook install` (см. README, «Гейт коммита»). Обойти разово — # `LEFTHOOK=0 git commit …`; обход законен ровно для того случая, когда чинить @@ -18,6 +19,11 @@ pre-commit: parallel: true jobs: + # Обе проверки документов идут по всему репозиторию, и это не недосмотр. + # copies.py сверяет копию с домом, а дом лежит в другом файле, которого в + # индексе может не быть: список staged дал бы «копии дословны» там, где + # правка дома их и разошлась. frontmatter.py смотрел бы поимённо, но весь + # обход стоит сотые доли секунды — платить за него нечем. - name: фронтматтеры glob: "*.md" run: python3 scripts/frontmatter.py @@ -26,23 +32,22 @@ pre-commit: glob: "*.md" run: python3 scripts/copies.py - # Секунды, а не миллисекунды: рендер идёт настоящим mermaid-cli. Поэтому - # glob — правка одних только скриптов проходит гейт мгновенно. Нет ни - # `mmdc`, ни `npx` — код 3, коммит отказан: «не проверено» здесь не то же - # самое, что «проверено и сошлось». + # Самая дорогая проверка: каждый блок — свой запуск mermaid-cli со своим + # chromium. Отсюда и staged-файлы вместо обхода, и параллель внутри самого + # скрипта: репозиторий целиком — 3 секунды, один файл — одна. - name: диаграммы glob: "*.md" - run: python3 scripts/diagrams.py + run: python3 scripts/diagrams.py {staged_files} - # Скрипты — под своим glob и через uv: версии линтеров прибиты точно, и - # `uv run` берёт именно их, а не то, что оказалось в PATH. Обе проверки - # укладываются в доли секунды на весь репозиторий, поэтому проверяется он - # целиком, а не изменённые файлы: находка в чужом файле здесь означает, что - # правка сломала соседа. + # Линтеры — через uv: версии прибиты точно, и `uv run` берёт именно их, а не + # то, что оказалось в PATH. `--fix` чинит безопасное сам, `stage_fixed` + # доносит починку до этого же коммита — иначе она осталась бы в рабочем + # дереве, а в историю уехал бы невычищенный файл. - name: ruff glob: "*.py" - run: uv run ruff check . + stage_fixed: true + run: uv run ruff check --fix {staged_files} - name: pyrefly glob: "*.py" - run: uv run pyrefly check + run: uv run pyrefly check {staged_files} diff --git a/scripts/diagrams.py b/scripts/diagrams.py index 1ff631b..d2a9127 100644 --- a/scripts/diagrams.py +++ b/scripts/diagrams.py @@ -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} файлах")