добавлено окружение разработки: контейнер и гейт ревью

- сервис поднимается через `task up`/`task restart`, данные вынесены в ./data
  и переживают пересборку — поток с телефона не прерывается
- `task gate` гоняет build/vet/lint/test/race/покрытие диффа/миграции, плюс два
  шага под healthlog: данные о здоровье в индексе и синхронность образцов конфига
- `task review:context` собирает вход для архитектурного прохода ревью
This commit is contained in:
av
2026-08-01 14:11:41 +03:00
parent 2ef43e1c2c
commit 505664acf1
9 changed files with 631 additions and 2 deletions
+250
View File
@@ -0,0 +1,250 @@
#!/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")
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)
config_changed = any(f.startswith("internal/config/") 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", "миграции и код не менялись")
# --- ER-схема синхронна с миграциями ---
# Меняем структуру — обновляем ER-схему в том же change. Проверка по
# диффу, потому и живёт здесь, а не в правиле линтера.
if migrations_changed:
if "docs/database.md" in changed:
record(OK, "er-schema")
else:
record(FAIL, "er-schema", "миграция изменена, а docs/database.md — нет")
# --- Образцы конфигурации синхронны с его структурой ---
# Конвенция: config.example.toml самодокументируемый и полный. Забытое поле
# обнаруживается не тестом, а тем, что через полгода никто не знает о его
# существовании, — поэтому проверяем механически.
if config_changed:
samples = [s for s in ("config.example.toml", "config.docker.toml") if s not in changed]
if samples:
record(FAIL, "config-samples",
"internal/config изменён, а образцы — нет: " + ", ".join(samples))
else:
record(OK, "config-samples")
# --- Секреты ---
# Гоняем всегда: секрет утекает из любого файла, не только из кода. Для
# healthlog это ещё и данные о здоровье — они чувствительнее токенов.
if shutil.which("gitleaks"):
step("gitleaks", ["gitleaks", "git", "--no-banner"], "возможен секрет в истории/индексе")
else:
record(SKIP, "gitleaks", "gitleaks не установлен")
# --- Данные не утекли в репозиторий ---
# Каталог ./data (база + сырой архив) под .gitignore. Попадание любого его
# файла в индекс означает утечку выгрузок Apple Health в историю git,
# откуда их уже не убрать простым коммитом.
tracked_data = [f for f in git("ls-files").splitlines()
if f.startswith("data/") or f.endswith((".db", ".db-wal", ".db-shm"))]
if tracked_data:
record(FAIL, "no-health-data",
"данные о здоровье под контролем версий: " + ", ".join(tracked_data[:5]))
else:
record(OK, "no-health-data")
# --- Уязвимости зависимостей ---
# Не блокирует: находка тут — состояние зависимостей и тулчейна, а не диффа.
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:<15} {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())