ревью: скрипты гейта на python3, шаги выбираются по изменённым файлам
gate.sh и review-context.sh переписаны на python3 — в scripts/ уже жил diff-coverage.py, а разбор вывода git и сборка сводки на shell читались хуже, чем работали. Гейт больше не гоняет go-шаги впустую: build, vet, lint, gofmt, тесты, -race, покрытие и govulncheck запускаются, только если в диффе есть .go либо go.mod/go.sum; миграции — если тронуты миграции или код. Правка документации проходит гейт за секунды вместо минуты. Пропуск при этом не молчит: он в сводке с причиной и уезжает в границы покрытия, а charter гейта различает «код не трогали» (корректно) и «инструмента нет» (настоящая дыра). Изменённые файлы считаем как объединение диффа с базой, рабочего дерева и новых файлов: гейт гоняют и до коммита, и после, а лишний прогон шага дешевле пропущенного. Заодно govulncheck перестал рапортовать «уязвимостей: 0» когда он просто не смог отработать из-за несобирающегося кода — это SKIP, а не WARN. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -18,7 +18,7 @@ color: red
|
|||||||
|
|
||||||
1. Определи базу диффа: `git merge-base HEAD master` (на master — `HEAD~1`) или
|
1. Определи базу диффа: `git merge-base HEAD master` (на master — `HEAD~1`) или
|
||||||
возьми её из задания.
|
возьми её из задания.
|
||||||
2. Запусти `task gate BASE=<база>` (обёртка над `scripts/gate.sh`). Он гонит все
|
2. Запусти `task gate BASE=<база>` (обёртка над `scripts/gate.py`). Он гонит все
|
||||||
шаги до конца и печатает сводку `OK`/`FAIL`/`WARN`/`SKIP`; подробности — в
|
шаги до конца и печатает сводку `OK`/`FAIL`/`WARN`/`SKIP`; подробности — в
|
||||||
`tmp/gate/<шаг>.log`. Краснит гейт только `FAIL`.
|
`tmp/gate/<шаг>.log`. Краснит гейт только `FAIL`.
|
||||||
3. По каждому `FAIL` открой лог и прочитай **реальную** причину. Не пересказывай
|
3. По каждому `FAIL` открой лог и прочитай **реальную** причину. Не пересказывай
|
||||||
@@ -44,7 +44,9 @@ color: red
|
|||||||
него будут ссылаться как на доказательство.
|
него будут ссылаться как на доказательство.
|
||||||
- **`SKIP` любого шага** — идёт в границы покрытия дословно, с причиной. Молча
|
- **`SKIP` любого шага** — идёт в границы покрытия дословно, с причиной. Молча
|
||||||
пропущенная проверка — это ложное ощущение проверенности, ровно то, ради чего
|
пропущенная проверка — это ложное ощущение проверенности, ровно то, ради чего
|
||||||
гейт и заводился.
|
гейт и заводился. Различай две причины: «код не трогали» — корректный пропуск
|
||||||
|
(шаги выбираются по изменённым файлам), а «инструмент не установлен» или «не
|
||||||
|
отработал» — настоящая дыра, и её надо назвать в отчёте.
|
||||||
- **`WARN` от `govulncheck`** — гейт не краснеет, но находка нужна. Открой
|
- **`WARN` от `govulncheck`** — гейт не краснеет, но находка нужна. Открой
|
||||||
`tmp/gate/govulncheck.log` и посмотри трассы вызовов: уязвимость, приехавшая с
|
`tmp/gate/govulncheck.log` и посмотри трассы вызовов: уязвимость, приехавшая с
|
||||||
зависимостью **этого** change, — `major`; уязвимость в стандартной библиотеке
|
зависимостью **этого** change, — `major`; уязвимость в стандартной библиотеке
|
||||||
|
|||||||
@@ -63,6 +63,10 @@ description: Конвейер ревью изменений jellybit — дет
|
|||||||
верификация**: изменённые строки без покрытия, конкурентность без теста с
|
верификация**: изменённые строки без покрытия, конкурентность без теста с
|
||||||
параллельным доступом, флаки-тест (не ниже `major`), недоступный инструмент.
|
параллельным доступом, флаки-тест (не ниже `major`), недоступный инструмент.
|
||||||
|
|
||||||
|
Шаги выбираются по изменённым файлам: правка документации не гоняет тесты,
|
||||||
|
линтеры и `-race`. Пропуск при этом не молчит — он виден в сводке с причиной и
|
||||||
|
уезжает в границы покрытия, как и любой другой `SKIP`.
|
||||||
|
|
||||||
## Стадия 1 — Conformance (обязательна во всех профилях)
|
## Стадия 1 — Conformance (обязательна во всех профилях)
|
||||||
|
|
||||||
Два applicative-прохода: оба применяют **записанный** критерий, оба дешёвые,
|
Два applicative-прохода: оба применяют **записанный** критерий, оба дешёвые,
|
||||||
|
|||||||
@@ -49,7 +49,7 @@
|
|||||||
- `jellybit-review-code` **не запускался ни в одном профиле** — charter обещал
|
- `jellybit-review-code` **не запускался ни в одном профиле** — charter обещал
|
||||||
«проход профиля `quick`», а `quick` состоял из стадий 0, 1, 5. Проход, который
|
«проход профиля `quick`», а `quick` состоял из стадий 0, 1, 5. Проход, который
|
||||||
нельзя запустить, нельзя и откалибровать. Включён стадией 1.
|
нельзя запустить, нельзя и откалибровать. Включён стадией 1.
|
||||||
- `scripts/review-context.sh` выгружал `go doc -short` по всему модулю — 264
|
- `review-context` выгружал `go doc -short` по всему модулю — 264
|
||||||
строки из 458. Убрано: граф зависимостей, который иначе не восстановить, — это
|
строки из 458. Убрано: граф зависимостей, который иначе не восстановить, — это
|
||||||
21 строка, а публичную поверхность агент вытянет `go doc` сам по нужному месту.
|
21 строка, а публичную поверхность агент вытянет `go doc` сам по нужному месту.
|
||||||
- Из `calibration.md` убрана секция «дополнительных метрик» (precision,
|
- Из `calibration.md` убрана секция «дополнительных метрик» (precision,
|
||||||
@@ -78,7 +78,7 @@
|
|||||||
| матчинг ошибки по тексту сообщения | `internal/archrules` | errors.md |
|
| матчинг ошибки по тексту сообщения | `internal/archrules` | errors.md |
|
||||||
| `AUTOINCREMENT`, `DEFAULT (datetime('now'))` в новых миграциях | `internal/archrules` | database.md |
|
| `AUTOINCREMENT`, `DEFAULT (datetime('now'))` в новых миграциях | `internal/archrules` | database.md |
|
||||||
| транспорты не знают друг о друге, ядро не знает о транспортах | `internal/archrules` | CLAUDE.md (осталась одна строка принципа) |
|
| транспорты не знают друг о друге, ядро не знает о транспортах | `internal/archrules` | CLAUDE.md (осталась одна строка принципа) |
|
||||||
| ER-схема обновлена вместе с миграцией | `scripts/gate.sh` (по диффу) | — |
|
| ER-схема обновлена вместе с миграцией | `scripts/gate.py` (по диффу) | — |
|
||||||
|
|
||||||
Правки кода под новые правила: `logging.StartCall` как единая точка отсчёта
|
Правки кода под новые правила: `logging.StartCall` как единая точка отсчёта
|
||||||
длительности внешних вызовов, `store.Now` вместо `time.Now` в `httpapi` и
|
длительности внешних вызовов, `store.Now` вместо `time.Now` в `httpapi` и
|
||||||
|
|||||||
+2
-2
@@ -44,12 +44,12 @@ tasks:
|
|||||||
gate:
|
gate:
|
||||||
desc: 'Детерминированный гейт ревью: build/vet/lint/test/race/покрытие диффа/миграции/секреты. BASE=<rev> — база диффа'
|
desc: 'Детерминированный гейт ревью: build/vet/lint/test/race/покрытие диффа/миграции/секреты. BASE=<rev> — база диффа'
|
||||||
cmds:
|
cmds:
|
||||||
- bash scripts/gate.sh {{.BASE}}
|
- python3 scripts/gate.py {{.BASE}}
|
||||||
|
|
||||||
review:context:
|
review:context:
|
||||||
desc: 'Вход для архитектурного прохода ревью: пакеты, граф зависимостей, инвентарь концепций'
|
desc: 'Вход для архитектурного прохода ревью: пакеты, граф зависимостей, инвентарь концепций'
|
||||||
cmds:
|
cmds:
|
||||||
- bash scripts/review-context.sh
|
- python3 scripts/review-context.py
|
||||||
|
|
||||||
tidy:
|
tidy:
|
||||||
desc: go mod tidy
|
desc: go mod tidy
|
||||||
|
|||||||
Executable
+226
@@ -0,0 +1,226 @@
|
|||||||
|
#!/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)
|
||||||
|
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-схема синхронна с миграциями ---
|
||||||
|
# docs/conventions/database.md: меняем структуру — обновляем ER-схему в том
|
||||||
|
# же change. Проверка по диффу, поэтому живёт здесь, а не в archrules.
|
||||||
|
if migrations_changed:
|
||||||
|
if "docs/specs/database.md" in changed:
|
||||||
|
record(OK, "er-schema")
|
||||||
|
else:
|
||||||
|
record(FAIL, "er-schema", "миграция изменена, а docs/specs/database.md — нет")
|
||||||
|
|
||||||
|
# --- Секреты ---
|
||||||
|
# Гоняем всегда: секрет утекает из любого файла, не только из кода.
|
||||||
|
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())
|
||||||
-157
@@ -1,157 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Детерминированный гейт ревью: прогоняет всё, у чего есть объективный оракул,
|
|
||||||
# и печатает сводку. В отличие от `task test`/`task lint` не останавливается на
|
|
||||||
# первом отказе — ревьюверу нужна полная картина, а не первая упавшая команда.
|
|
||||||
#
|
|
||||||
# Использование: scripts/gate.sh [<base-rev>]
|
|
||||||
# base-rev — база для диффа (покрытие изменённых строк). По умолчанию
|
|
||||||
# определяется как merge-base с master; для самого master — HEAD~1.
|
|
||||||
#
|
|
||||||
# Коды возврата: 0 — все обязательные шаги зелёные, 1 — есть красные.
|
|
||||||
# Пропущенные шаги (нет инструмента) НЕ красят гейт, но попадают в сводку:
|
|
||||||
# молча пропущенная проверка — это ложное ощущение проверенности. То же с WARN:
|
|
||||||
# отказ, который почти всегда унаследован (уязвимость в зависимости или в
|
|
||||||
# стандартной библиотеке), не должен краснить каждый прогон ревью — иначе
|
|
||||||
# красный гейт перестают читать. Разбирает такой отказ агент.
|
|
||||||
|
|
||||||
set -u
|
|
||||||
|
|
||||||
OUT_DIR="tmp/gate"
|
|
||||||
mkdir -p "$OUT_DIR"
|
|
||||||
|
|
||||||
BASE="${1:-}"
|
|
||||||
if [ -z "$BASE" ]; then
|
|
||||||
if git rev-parse --verify -q master >/dev/null && \
|
|
||||||
[ "$(git rev-parse --abbrev-ref HEAD)" != "master" ]; then
|
|
||||||
BASE="$(git merge-base HEAD master)"
|
|
||||||
else
|
|
||||||
BASE="HEAD~1"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
FAILED=0
|
|
||||||
SUMMARY=""
|
|
||||||
|
|
||||||
record() { # <status> <name> <hint>
|
|
||||||
SUMMARY="${SUMMARY}$1\t$2\t$3\n"
|
|
||||||
[ "$1" = "FAIL" ] && FAILED=1
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
step() { # <name> <hint-on-fail> <cmd...>
|
|
||||||
local name="$1" hint="$2"
|
|
||||||
shift 2
|
|
||||||
if "$@" >"$OUT_DIR/$name.log" 2>&1; then
|
|
||||||
record OK "$name" ""
|
|
||||||
else
|
|
||||||
record FAIL "$name" "$hint → $OUT_DIR/$name.log"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
echo "== gate: база диффа $BASE =="
|
|
||||||
|
|
||||||
# --- Компиляция и статика ---
|
|
||||||
step build "не собирается" go build ./...
|
|
||||||
step vet "" go vet ./...
|
|
||||||
if command -v golangci-lint >/dev/null; then
|
|
||||||
step lint "" golangci-lint run
|
|
||||||
else
|
|
||||||
record SKIP lint "golangci-lint не установлен (task setup)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
gofmt_out="$(gofmt -l . 2>/dev/null | grep -v '^tmp/' || true)"
|
|
||||||
if [ -n "$gofmt_out" ]; then
|
|
||||||
record FAIL gofmt "не отформатировано: $(echo "$gofmt_out" | tr '\n' ' ')"
|
|
||||||
else
|
|
||||||
record OK gofmt ""
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- Тесты ---
|
|
||||||
step test "" go test -count=1 ./...
|
|
||||||
|
|
||||||
# Флаки: повторный прогон тех же тестов. Расхождение результатов — находка не
|
|
||||||
# ниже major (тест, который иногда зелёный, не является оракулом ни для чего).
|
|
||||||
if [ -f "$OUT_DIR/test.log" ] && grep -q '^ok\|^---' "$OUT_DIR/test.log"; then
|
|
||||||
if go test -count=1 ./... >"$OUT_DIR/test-repeat.log" 2>&1; then
|
|
||||||
if grep -q "^FAIL" "$OUT_DIR/test.log"; then
|
|
||||||
record FAIL flaky "прогон 1 красный, прогон 2 зелёный — флаки-тест"
|
|
||||||
else
|
|
||||||
record OK flaky ""
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
if grep -q "^FAIL" "$OUT_DIR/test.log"; then
|
|
||||||
record OK flaky "" # стабильно красный — это не флаки, отработает шаг test
|
|
||||||
else
|
|
||||||
record FAIL flaky "прогон 1 зелёный, прогон 2 красный — флаки-тест"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- Гонки ---
|
|
||||||
if command -v gcc >/dev/null; then
|
|
||||||
step race "детектор гонок сработал" env CGO_ENABLED=1 go test -race -count=1 ./...
|
|
||||||
else
|
|
||||||
record SKIP race "нет gcc: -race требует cgo. Гонки НЕ проверены — в границы покрытия"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- Покрытие изменённых строк ---
|
|
||||||
if go test -count=1 -coverprofile="$OUT_DIR/cover.out" ./... >"$OUT_DIR/cover.log" 2>&1; then
|
|
||||||
if python3 scripts/diff-coverage.py "$OUT_DIR/cover.out" "$BASE" \
|
|
||||||
>"$OUT_DIR/diff-coverage.log" 2>&1; then
|
|
||||||
record OK diff-coverage "$(head -1 "$OUT_DIR/diff-coverage.log")"
|
|
||||||
else
|
|
||||||
record SKIP diff-coverage "не удалось посчитать → $OUT_DIR/diff-coverage.log"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
record SKIP diff-coverage "прогон с профилем не собрался → $OUT_DIR/cover.log"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- Миграции на чистой схеме ---
|
|
||||||
step migrations "миграции не накатываются с нуля" \
|
|
||||||
go test -count=1 -run Migration ./internal/store/...
|
|
||||||
|
|
||||||
# --- ER-схема синхронна с миграциями ---
|
|
||||||
# docs/conventions/database.md: структуру меняем — обновляем ER-схему в том же
|
|
||||||
# change. Проверка по диффу, поэтому живёт здесь, а не в internal/archrules.
|
|
||||||
changed="$(git diff --name-only "$BASE"...HEAD 2>/dev/null || true)"
|
|
||||||
if echo "$changed" | grep -q '^internal/store/migrations/'; then
|
|
||||||
if echo "$changed" | grep -q '^docs/specs/database.md$'; then
|
|
||||||
record OK er-schema ""
|
|
||||||
else
|
|
||||||
record FAIL er-schema "миграция изменена, а docs/specs/database.md — нет"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- Секреты ---
|
|
||||||
if command -v gitleaks >/dev/null; then
|
|
||||||
step gitleaks "возможен секрет в истории/индексе" gitleaks git --no-banner
|
|
||||||
else
|
|
||||||
record SKIP gitleaks "gitleaks не установлен"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- Уязвимости зависимостей ---
|
|
||||||
# Не блокирует: находка тут — состояние зависимостей и тулчейна, а не диффа.
|
|
||||||
# Исключение (уязвимость приехала с новой зависимостью в этом change) разбирает
|
|
||||||
# агент по списку вызовов.
|
|
||||||
if command -v govulncheck >/dev/null; then
|
|
||||||
if govulncheck ./... >"$OUT_DIR/govulncheck.log" 2>&1; then
|
|
||||||
record OK govulncheck ""
|
|
||||||
else
|
|
||||||
record WARN govulncheck "достижимо из кода уязвимостей: $(grep -c '^Vulnerability #' "$OUT_DIR/govulncheck.log") → $OUT_DIR/govulncheck.log"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
record SKIP govulncheck "govulncheck не установлен (task setup)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo
|
|
||||||
echo "== сводка =="
|
|
||||||
printf "%b" "$SUMMARY" | while IFS=$'\t' read -r status name hint; do
|
|
||||||
printf "%-5s %-14s %s\n" "$status" "$name" "$hint"
|
|
||||||
done
|
|
||||||
|
|
||||||
echo
|
|
||||||
if [ "$FAILED" -eq 1 ]; then
|
|
||||||
echo "ГЕЙТ КРАСНЫЙ — опиниативные проходы не запускаются"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "гейт зелёный (шаги WARN и SKIP см. в сводке — они идут в находки и в границы покрытия)"
|
|
||||||
Executable
+94
@@ -0,0 +1,94 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Вход для архитектурного прохода ревью: то, чего нет в диффе.
|
||||||
|
|
||||||
|
Агент, видящий только `git diff`, физически не может судить об архитектуре — он
|
||||||
|
не знает, какие понятия в проекте уже есть и как они называются. Скрипт собирает
|
||||||
|
дерево пакетов с назначением, граф внутренних зависимостей и инвентарь
|
||||||
|
существующих концепций.
|
||||||
|
|
||||||
|
Публичную поверхность пакетов намеренно НЕ выгружаем: дамп `go doc -short` по
|
||||||
|
всему модулю занимал больше половины вывода, а агент вытянет `go doc` по нужному
|
||||||
|
пакету сам. Здесь — только то, что иначе не восстановить.
|
||||||
|
|
||||||
|
Использование: scripts/review-context.py [> tmp/review-context.md]
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def go(*args: str) -> str:
|
||||||
|
return subprocess.run(
|
||||||
|
["go", *args], capture_output=True, text=True, check=True
|
||||||
|
).stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def module_path() -> str:
|
||||||
|
for line in Path("go.mod").read_text(encoding="utf-8").splitlines():
|
||||||
|
if line.startswith("module "):
|
||||||
|
return line.split(None, 1)[1].strip()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def scan(root: str, pattern: str) -> list[str]:
|
||||||
|
"""Строки нетестовых .go файлов под root, совпавшие с pattern."""
|
||||||
|
re_ = re.compile(pattern)
|
||||||
|
found = set()
|
||||||
|
for path in sorted(Path(root).rglob("*.go")):
|
||||||
|
if path.name.endswith("_test.go"):
|
||||||
|
continue
|
||||||
|
for line in path.read_text(encoding="utf-8").splitlines():
|
||||||
|
if re_.search(line):
|
||||||
|
found.add(line.strip())
|
||||||
|
return sorted(found)
|
||||||
|
|
||||||
|
|
||||||
|
def block(title: str, lines: list[str], lang: str = "") -> None:
|
||||||
|
print(f"### {title}\n")
|
||||||
|
print(f"```{lang}")
|
||||||
|
print("\n".join(lines) if lines else "— пусто")
|
||||||
|
print("```\n")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
mod = module_path()
|
||||||
|
packages = [p for p in go("list", "./...").splitlines() if not p.endswith("/migrations")]
|
||||||
|
|
||||||
|
print("# Контекст проекта для архитектурного ревью\n")
|
||||||
|
print(f"Сгенерировано `scripts/review-context.py`. Модуль: `{mod}`.\n")
|
||||||
|
|
||||||
|
print("## Пакеты и назначение\n")
|
||||||
|
print("```")
|
||||||
|
for entry in go("list", "-f", "{{.ImportPath}}|{{.Doc}}", "./...").splitlines():
|
||||||
|
path, _, doc = entry.partition("|")
|
||||||
|
short = path.removeprefix(mod + "/")
|
||||||
|
print(f"{short:<34} {doc or '— (нет doc-комментария пакета)'}")
|
||||||
|
print("```\n")
|
||||||
|
|
||||||
|
print("## Граф внутренних зависимостей\n")
|
||||||
|
print("Только импорты внутри модуля. Стрелка A -> B означает «A зависит от B».\n")
|
||||||
|
print("```")
|
||||||
|
for pkg in packages:
|
||||||
|
imports = go("list", "-f", '{{range .Imports}}{{.}}\n{{end}}', pkg).splitlines()
|
||||||
|
deps = sorted({i.removeprefix(mod + "/") for i in imports if i.startswith(mod + "/")})
|
||||||
|
if deps:
|
||||||
|
print(f"{pkg.removeprefix(mod + '/')} -> {' '.join(deps)}")
|
||||||
|
print("```\n")
|
||||||
|
|
||||||
|
print("## Инвентарь концепций\n")
|
||||||
|
print("Как в проекте уже называются вещи. Новое понятие вводим, только"
|
||||||
|
" убедившись,\nчто его нельзя выразить существующими.\n")
|
||||||
|
|
||||||
|
block("Доменные ошибки (sentinel)", scan("internal", r"^var Err\w+ = errors\.New"))
|
||||||
|
block("Состояния загрузки", scan("internal/store", r"State\w+\s+State\s*="))
|
||||||
|
block("Секции конфигурации", scan("internal/config", r'toml:"'))
|
||||||
|
block("Публичные команды воркера (вызываются транспортами)",
|
||||||
|
scan("internal/worker", r"^func \(w \*Worker\) [A-Z]"))
|
||||||
|
block("Capabilities OpenSpec", sorted(p.name for p in Path("openspec/specs").iterdir()))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Вход для архитектурного прохода ревью: то, чего нет в диффе.
|
|
||||||
#
|
|
||||||
# Агент, видящий только `git diff`, физически не может судить об архитектуре —
|
|
||||||
# он не знает, какие понятия в проекте уже есть и как они называются. Скрипт
|
|
||||||
# собирает дерево пакетов с назначением, граф внутренних зависимостей и
|
|
||||||
# инвентарь существующих концепций.
|
|
||||||
#
|
|
||||||
# Публичную поверхность пакетов намеренно НЕ выгружаем: дамп `go doc -short` по
|
|
||||||
# всему модулю занимал больше половины вывода, а агент вытянет `go doc` по
|
|
||||||
# нужному пакету сам. Здесь — только то, что иначе не восстановить.
|
|
||||||
#
|
|
||||||
# Использование: scripts/review-context.sh [> tmp/review-context.md]
|
|
||||||
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
MOD="$(awk '/^module /{print $2}' go.mod)"
|
|
||||||
PKGS="$(go list ./... | grep -v '/migrations$')"
|
|
||||||
|
|
||||||
echo "# Контекст проекта для архитектурного ревью"
|
|
||||||
echo
|
|
||||||
echo "Сгенерировано \`scripts/review-context.sh\`. Модуль: \`$MOD\`."
|
|
||||||
echo
|
|
||||||
|
|
||||||
echo "## Пакеты и назначение"
|
|
||||||
echo
|
|
||||||
echo '```'
|
|
||||||
go list -f '{{.ImportPath}}|{{.Doc}}' ./... | sed "s|^$MOD/||" |
|
|
||||||
awk -F'|' '{printf "%-34s %s\n", $1, ($2 == "" ? "— (нет doc-комментария пакета)" : $2)}'
|
|
||||||
echo '```'
|
|
||||||
echo
|
|
||||||
|
|
||||||
echo "## Граф внутренних зависимостей"
|
|
||||||
echo
|
|
||||||
echo "Только импорты внутри модуля. Стрелка A -> B означает «A зависит от B»."
|
|
||||||
echo
|
|
||||||
echo '```'
|
|
||||||
for p in $PKGS; do
|
|
||||||
deps="$(go list -f '{{range .Imports}}{{.}}
|
|
||||||
{{end}}' "$p" | grep "^$MOD/" | sed "s|^$MOD/||" | sort -u | tr '\n' ' ')"
|
|
||||||
[ -n "$deps" ] && echo "${p#"$MOD"/} -> $deps"
|
|
||||||
done
|
|
||||||
echo '```'
|
|
||||||
echo
|
|
||||||
|
|
||||||
echo "## Инвентарь концепций"
|
|
||||||
echo
|
|
||||||
echo "Как в проекте уже называются вещи. Новое понятие вводим, только убедившись,"
|
|
||||||
echo "что его нельзя выразить существующими."
|
|
||||||
echo
|
|
||||||
echo "### Доменные ошибки (sentinel)"
|
|
||||||
echo '```'
|
|
||||||
grep -rhn "^var Err[A-Za-z]* = errors.New" --include='*.go' internal/ |
|
|
||||||
sed 's/^[0-9]*://' | sort -u
|
|
||||||
echo '```'
|
|
||||||
echo
|
|
||||||
echo "### Состояния загрузки"
|
|
||||||
echo '```'
|
|
||||||
grep -rh "State[A-Za-z]* *State *= " --include='*.go' internal/store/ | sed 's/^\s*//' | sort -u
|
|
||||||
echo '```'
|
|
||||||
echo
|
|
||||||
echo "### Секции конфигурации"
|
|
||||||
echo '```'
|
|
||||||
grep -rh 'toml:"' --include='*.go' internal/config/ | sed 's/^\s*//' | sort -u
|
|
||||||
echo '```'
|
|
||||||
echo
|
|
||||||
echo "### Публичные команды воркера (вызываются транспортами)"
|
|
||||||
echo '```'
|
|
||||||
grep -rhn "^func (w \*Worker) [A-Z]" --include='*.go' internal/worker/ | sed 's/^[0-9]*://' | sort -u
|
|
||||||
echo '```'
|
|
||||||
echo
|
|
||||||
echo "### Capabilities OpenSpec"
|
|
||||||
echo '```'
|
|
||||||
ls openspec/specs/
|
|
||||||
echo '```'
|
|
||||||
Reference in New Issue
Block a user