From 6792f7082ab2e02b96073b0247f6404b562afb37 Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Thu, 23 Jul 2026 18:17:28 +0300 Subject: [PATCH] =?UTF-8?q?=D1=80=D0=B5=D0=B2=D1=8C=D1=8E:=20=D0=B4=D0=B5?= =?UTF-8?q?=D1=82=D0=B5=D1=80=D0=BC=D0=B8=D0=BD=D0=B8=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D0=BD=D0=BD=D1=8B=D0=B9=20=D0=B3=D0=B5=D0=B9=D1=82=20tas?= =?UTF-8?q?k=20gate=20=D0=B8=20=D0=BA=D0=B0=D1=80=D1=82=D0=B0=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D0=B5=D0=BA=D1=82=D0=B0=20=D0=B4=D0=BB=D1=8F=20?= =?UTF-8?q?=D0=B0=D1=80=D1=85=D0=B8=D1=82=D0=B5=D0=BA=D1=82=D1=83=D1=80?= =?UTF-8?q?=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=BF=D1=80=D0=BE=D1=85=D0=BE=D0=B4?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/gate.sh гонит всё, у чего есть объективный оракул (build, vet, lint, gofmt, тесты, повтор на флаки, -race, покрытие изменённых строк, миграции, ER-схема по диффу, gitleaks, govulncheck), не останавливаясь на первом отказе: ревью нужна полная картина. Пропущенный шаг попадает в сводку — молча пропущенная проверка даёт ложное ощущение проверенности. scripts/diff-coverage.py считает покрытие именно изменённых строк: общий процент по пакету для ревью бесполезен. scripts/review-context.sh собирает вход, которого нет в диффе — пакеты с назначением, граф внутренних зависимостей, публичную поверхность и инвентарь концепций. Агент, видящий только дифф, не знает словаря проекта и потому не может судить об архитектуре. Co-Authored-By: Claude Opus 4.8 (1M context) --- Taskfile.yml | 10 +++ scripts/diff-coverage.py | 112 +++++++++++++++++++++++++++++ scripts/gate.sh | 147 ++++++++++++++++++++++++++++++++++++++ scripts/review-context.sh | 86 ++++++++++++++++++++++ 4 files changed, 355 insertions(+) create mode 100755 scripts/diff-coverage.py create mode 100755 scripts/gate.sh create mode 100755 scripts/review-context.sh diff --git a/Taskfile.yml b/Taskfile.yml index 297cff6..bf84336 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -40,6 +40,16 @@ tasks: cmds: - golangci-lint run + gate: + desc: 'Детерминированный гейт ревью: build/vet/lint/test/race/покрытие диффа/миграции/секреты. BASE= — база диффа' + cmds: + - bash scripts/gate.sh {{.BASE}} + + review:context: + desc: 'Вход для архитектурного прохода ревью: пакеты, граф зависимостей, публичная поверхность, инвентарь концепций' + cmds: + - bash scripts/review-context.sh + tidy: desc: go mod tidy cmds: diff --git a/scripts/diff-coverage.py b/scripts/diff-coverage.py new file mode 100755 index 0000000..e9ec139 --- /dev/null +++ b/scripts/diff-coverage.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +"""Покрытие изменённых строк тестами. + +Складывает `go test -coverprofile` и `git diff -U0 `: показывает, какие +изменённые исполняемые строки не покрыты ни одним тестом. Общий процент по +пакету бесполезен для ревью — важно, покрыт ли именно новый код. + +Использование: scripts/diff-coverage.py +""" + +import re +import subprocess +import sys +from collections import defaultdict + +HUNK = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@") +BLOCK = re.compile(r"^(.+):(\d+)\.\d+,(\d+)\.\d+ (\d+) (\d+)$") + + +def module_path() -> str: + with open("go.mod", encoding="utf-8") as f: + for line in f: + if line.startswith("module "): + return line.split(None, 1)[1].strip() + return "" + + +def coverage_blocks(profile: str, module: str): + """file -> [(start, end, count)] по репо-относительным путям.""" + blocks = defaultdict(list) + with open(profile, encoding="utf-8") as f: + for line in f: + m = BLOCK.match(line.strip()) + if not m: + continue + path, start, end, _stmts, count = m.groups() + if module and path.startswith(module + "/"): + path = path[len(module) + 1:] + blocks[path].append((int(start), int(end), int(count))) + return blocks + + +def changed_lines(base: str): + """file -> {номера добавленных/изменённых строк} для нетестовых .go.""" + out = subprocess.run( + ["git", "diff", "-U0", base, "--", "*.go"], + capture_output=True, text=True, check=True, + ).stdout + changed = defaultdict(set) + current = None + for line in out.splitlines(): + if line.startswith("+++ b/"): + path = line[6:] + current = None if path.endswith("_test.go") else path + elif line.startswith("@@") and current: + m = HUNK.match(line) + if m: + start = int(m.group(1)) + count = int(m.group(2) or 1) + changed[current].update(range(start, start + count)) + return changed + + +def main() -> int: + if len(sys.argv) != 3: + print(__doc__, file=sys.stderr) + return 2 + profile, base = sys.argv[1], sys.argv[2] + + blocks = coverage_blocks(profile, module_path()) + changed = changed_lines(base) + + total = uncovered = 0 + report = [] + for path in sorted(changed): + gaps = [] + for line in sorted(changed[path]): + covering = [b for b in blocks.get(path, []) if b[0] <= line <= b[1]] + if not covering: + continue # не исполняемая строка (объявление, комментарий, скобка) + total += 1 + if all(b[2] == 0 for b in covering): + uncovered += 1 + gaps.append(line) + if gaps: + report.append((path, gaps)) + + if total == 0: + print("изменённых исполняемых строк нет (или профиль не содержит этих пакетов)") + return 0 + + print(f"изменённых исполняемых строк: {total}, не покрыто: {uncovered}" + f" ({100 * (total - uncovered) // total}% покрытия диффа)") + for path, gaps in report: + print(f" {path}: {compact(gaps)}") + return 0 + + +def compact(lines): + """[3,4,5,9] -> '3-5,9'.""" + out, start, prev = [], lines[0], lines[0] + for line in lines[1:] + [None]: + if line == prev + 1: + prev = line + continue + out.append(str(start) if start == prev else f"{start}-{prev}") + start = prev = line + return ",".join(out) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/gate.sh b/scripts/gate.sh new file mode 100755 index 0000000..90d9b2e --- /dev/null +++ b/scripts/gate.sh @@ -0,0 +1,147 @@ +#!/usr/bin/env bash +# Детерминированный гейт ревью: прогоняет всё, у чего есть объективный оракул, +# и печатает сводку. В отличие от `task test`/`task lint` не останавливается на +# первом отказе — ревьюверу нужна полная картина, а не первая упавшая команда. +# +# Использование: scripts/gate.sh [] +# base-rev — база для диффа (покрытие изменённых строк). По умолчанию +# определяется как merge-base с master; для самого master — HEAD~1. +# +# Коды возврата: 0 — все обязательные шаги зелёные, 1 — есть красные. +# Пропущенные шаги (нет инструмента) НЕ красят гейт, но попадают в сводку: +# молча пропущенная проверка — это ложное ощущение проверенности. + +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() { # + SUMMARY="${SUMMARY}$1\t$2\t$3\n" + [ "$1" = "FAIL" ] && FAILED=1 + return 0 +} + +step() { # + 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 + +# --- Уязвимости зависимостей --- +if command -v govulncheck >/dev/null; then + step govulncheck "" govulncheck ./... +else + record SKIP govulncheck "govulncheck не установлен (go install golang.org/x/vuln/cmd/govulncheck@latest)" +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 "гейт зелёный (пропущенные шаги см. в сводке — они идут в границы покрытия)" diff --git a/scripts/review-context.sh b/scripts/review-context.sh new file mode 100755 index 0000000..c3b7acd --- /dev/null +++ b/scripts/review-context.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# Вход для архитектурного прохода ревью: то, чего нет в диффе. +# +# Агент, видящий только `git diff`, физически не может судить об архитектуре — +# он не знает, какие понятия в проекте уже есть и как они называются. Скрипт +# собирает дерево пакетов с назначением, публичную поверхность, граф внутренних +# зависимостей и инвентарь существующих концепций. +# +# Использование: 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 +for p in $PKGS; do + short="${p#"$MOD"/}" + body="$(go doc -short "$p" 2>/dev/null | grep -v '^$' || true)" + [ -z "$body" ] && continue + echo "### $short" + echo '```go' + echo "$body" + echo '```' + echo +done + +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 '```'