Files
av 42d5b73a04 docs: перевод документации на канон av-dev
- Раскладка docs/ приведена к канону 2: заведены passport/architecture/
  database/security/review и research; docs/specs, drafts, backlog, review/
  и BRIEF.md разобраны и удалены, беклог переехал в docs/tasks (34 задачи,
  6 целей, слаги на английский).
- Нарративы specs удалены как дубли openspec-спек после поимённой сверки;
  остаток заведён задачами (редактор маппинга ревью, крайние случаи
  именования), отказ от сущности title промоутнут в ADR.
- Проектные копии агентов и скиллов ревью/пайплайна удалены в пользу
  плагинов av-dev-pm и av-dev-pipeline; в task gate добавлен шаг canon
  вместо er-schema.
2026-08-04 09:27:26 +03:00

254 lines
12 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Детерминированный гейт ревью.
Прогоняет всё, у чего есть объективный оракул, и печатает сводку. В отличие от
`task test`/`task lint` не останавливается на первом отказе — ревьюверу нужна
полная картина, а не первая упавшая команда.
Использование: scripts/gate.py [<base-rev>]
base-rev — база для диффа. По умолчанию merge-base с master; на самом
master — HEAD~1.
Шаги выбираются по изменённым файлам: правка документации не гоняет тесты и
линтеры. Пропущенный шаг всегда виден в сводке с причиной — молча пропущенная
проверка даёт ложное ощущение проверенности, а это ровно то, ради чего гейт и
заводился.
Коды возврата: 0 — красных шагов нет, 1 — есть.
Статусы: OK, FAIL (краснит гейт), WARN (виден, но не блокирует), SKIP.
"""
import os
import shutil
import subprocess
import sys
from pathlib import Path
OUT_DIR = Path("tmp/gate")
# docs.py скилла av-dev-pm:canon — проверка раскладки docs/ против канона.
# Путь берётся из каталога маркетплейса (он без хеша версии, в отличие от
# cache/), переопределяется переменной DOCS_PY: переустановка плагина не должна
# требовать правки гейта. Если скрипта нет — шаг краснеет, а не пропускается:
# молча пропущенная проверка даёт ложное ощущение проверенности.
DOCS_PY_DEFAULT = (
Path.home()
/ ".claude/plugins/marketplaces/av-dev-skills"
/ "av-dev-pm/skills/canon/scripts/docs.py"
)
OK, FAIL, WARN, SKIP = "OK", "FAIL", "WARN", "SKIP"
summary: list[tuple[str, str, str]] = []
def record(status: str, name: str, hint: str = "") -> None:
summary.append((status, name, hint))
def git(*args: str) -> str:
return subprocess.run(
["git", *args], capture_output=True, text=True, check=True
).stdout.strip()
def base_rev(argv: list[str]) -> str:
if len(argv) > 1:
return argv[1]
on_master = git("rev-parse", "--abbrev-ref", "HEAD") == "master"
has_master = subprocess.run(
["git", "rev-parse", "--verify", "-q", "master"], capture_output=True
).returncode == 0
if has_master and not on_master:
return git("merge-base", "HEAD", "master")
return "HEAD~1"
def changed_files(base: str) -> list[str]:
"""Изменённые файлы: закоммиченное относительно базы + рабочее дерево + новые.
Берём объединение намеренно: гейт гоняют и до коммита, и после, и лишний
прогон шага дешевле пропущенного.
"""
files = set(git("diff", "--name-only", base).splitlines())
files |= set(git("ls-files", "--others", "--exclude-standard").splitlines())
return sorted(f for f in files if f)
def run(name: str, cmd: list[str], env: dict[str, str] | None = None) -> bool:
"""Выполняет шаг, складывает вывод в tmp/gate/<name>.log."""
log = OUT_DIR / f"{name}.log"
full_env = {**os.environ, **(env or {})}
proc = subprocess.run(cmd, capture_output=True, text=True, env=full_env)
log.write_text(proc.stdout + proc.stderr, encoding="utf-8")
return proc.returncode == 0
def step(name: str, cmd: list[str], hint: str = "", env: dict[str, str] | None = None) -> bool:
ok = run(name, cmd, env)
record(OK, name) if ok else record(
FAIL, name, f"{hint + ' → ' if hint else ''}{OUT_DIR}/{name}.log"
)
return ok
def main() -> int:
OUT_DIR.mkdir(parents=True, exist_ok=True)
base = base_rev(sys.argv)
changed = changed_files(base)
go_changed = any(f.endswith(".go") for f in changed)
deps_changed = any(f in ("go.mod", "go.sum") for f in changed)
migrations_changed = any(f.startswith("internal/store/migrations/") for f in changed)
code_changed = go_changed or deps_changed
no_code = "нет изменений в .go/go.mod — код не трогали"
print(f"== gate: база диффа {base}, изменённых файлов {len(changed)} ==")
if not code_changed:
print(" код не менялся — go-шаги пропускаются, см. сводку")
# --- Компиляция и статика ---
if code_changed:
step("build", ["go", "build", "./..."], "не собирается")
step("vet", ["go", "vet", "./..."])
if shutil.which("golangci-lint"):
step("lint", ["golangci-lint", "run"])
else:
record(SKIP, "lint", "golangci-lint не установлен (task setup)")
unformatted = [
f for f in subprocess.run(
["gofmt", "-l", "."], capture_output=True, text=True
).stdout.split()
if not f.startswith("tmp/")
]
if unformatted:
record(FAIL, "gofmt", "не отформатировано: " + " ".join(unformatted))
else:
record(OK, "gofmt")
else:
for name in ("build", "vet", "lint", "gofmt"):
record(SKIP, name, no_code)
# --- Тесты ---
if code_changed:
tests_ok = step("test", ["go", "test", "-count=1", "./..."])
# Флаки: повторный прогон. Тест, который иногда зелёный, не является
# оракулом ни для чего, поэтому расхождение — находка не ниже major.
# Стабильно красный набор флаки не проверяем: его разбирает шаг test.
if tests_ok:
if run("test-repeat", ["go", "test", "-count=1", "./..."]):
record(OK, "flaky")
else:
record(FAIL, "flaky", "прогон 1 зелёный, прогон 2 красный — флаки-тест")
else:
record(SKIP, "flaky", "набор красный — сперва чиним test")
else:
record(SKIP, "test", no_code)
record(SKIP, "flaky", no_code)
# --- Гонки ---
if not code_changed:
record(SKIP, "race", no_code)
elif shutil.which("gcc"):
step(
"race",
["go", "test", "-race", "-count=1", "./..."],
"гонка либо сборка тестов — смотри лог",
env={"CGO_ENABLED": "1"},
)
else:
record(SKIP, "race", "нет gcc: -race требует cgo. Гонки НЕ проверены")
# --- Покрытие изменённых строк ---
if not go_changed:
record(SKIP, "diff-coverage", "нет изменений в .go")
elif run("cover", ["go", "test", "-count=1", f"-coverprofile={OUT_DIR}/cover.out", "./..."]):
if run("diff-coverage", ["python3", "scripts/diff-coverage.py", f"{OUT_DIR}/cover.out", base]):
record(OK, "diff-coverage", (OUT_DIR / "diff-coverage.log").read_text().splitlines()[0])
else:
record(SKIP, "diff-coverage", f"не удалось посчитать → {OUT_DIR}/diff-coverage.log")
else:
record(SKIP, "diff-coverage", f"прогон с профилем не собрался → {OUT_DIR}/cover.log")
# --- Миграции на чистой схеме ---
if migrations_changed or go_changed:
step(
"migrations",
["go", "test", "-count=1", "-run", "Migration", "./internal/store/..."],
"миграции не накатываются с нуля",
)
else:
record(SKIP, "migrations", "миграции и код не менялись")
# --- Канон документации ---
# Раскладка docs/ против канона av-dev, битые ссылки, версия канона,
# нетронутые плейсхолдеры и две сверки с кодом — включая «миграция изменена,
# а database.md нет» (отдельного шага er-schema поэтому больше нет).
# База диффа обязательна: без неё сверка миграций со схемой не гоняется.
docs_py = Path(os.environ.get("DOCS_PY") or DOCS_PY_DEFAULT)
if not docs_py.is_file():
record(FAIL, "canon", f"docs.py не найден: {docs_py} — переустанови плагин"
" av-dev-pm или задай путь переменной DOCS_PY")
else:
log = OUT_DIR / "canon.log"
proc = subprocess.run(
["python3", str(docs_py), "check", "--dir", ".", "--base", base],
capture_output=True, text=True,
)
log.write_text(proc.stdout + proc.stderr, encoding="utf-8")
if proc.returncode == 0:
record(OK, "canon")
elif proc.returncode == 1:
record(FAIL, "canon", f"раскладка docs/ разошлась с каноном → {log}")
else:
# 2 — ошибка употребления, 3 — окружение, 4 — внутренний сбой.
# Это не дрейф документации, и чинится оно не правкой docs/.
record(FAIL, "canon", f"docs.py не отработал (код {proc.returncode}) → {log}")
# --- Секреты ---
# Гоняем всегда: секрет утекает из любого файла, не только из кода.
if shutil.which("gitleaks"):
step("gitleaks", ["gitleaks", "git", "--no-banner"], "возможен секрет в истории/индексе")
else:
record(SKIP, "gitleaks", "gitleaks не установлен")
# --- Уязвимости зависимостей ---
# Не блокирует: находка тут — состояние зависимостей и тулчейна, а не диффа.
# Уязвимость, приехавшую с новой зависимостью change, разбирает агент по
# трассам вызовов.
if not code_changed:
record(SKIP, "govulncheck", no_code)
elif shutil.which("govulncheck"):
if run("govulncheck", ["govulncheck", "./..."]):
record(OK, "govulncheck")
else:
# Ненулевой код возврата означает и найденные уязвимости, и отказ
# самого инструмента (чаще всего код не собирается). Различаем: без
# этого «уязвимостей: 0» выглядит как проверка, которой не было.
found = (OUT_DIR / "govulncheck.log").read_text().count("\nVulnerability #")
if found:
record(WARN, "govulncheck",
f"достижимо из кода уязвимостей: {found}{OUT_DIR}/govulncheck.log")
else:
record(SKIP, "govulncheck",
f"не отработал (обычно код не собирается) → {OUT_DIR}/govulncheck.log")
else:
record(SKIP, "govulncheck", "govulncheck не установлен (task setup)")
# --- Сводка ---
print("\n== сводка ==")
for status, name, hint in summary:
print(f"{status:<5} {name:<14} {hint}")
if any(s == FAIL for s, _, _ in summary):
print("\nГЕЙТ КРАСНЫЙ — опиниативные проходы не запускаются")
return 1
print("\nгейт зелёный (шаги WARN и SKIP см. в сводке — они идут в находки"
" и в границы покрытия)")
return 0
if __name__ == "__main__":
sys.exit(main())