ревью: детерминированный гейт task gate и карта проекта для архитектурного прохода

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) <noreply@anthropic.com>
This commit is contained in:
av
2026-07-23 18:17:28 +03:00
co-authored by Claude Opus 4.8
parent 1d6f8f3449
commit 6792f7082a
4 changed files with 355 additions and 0 deletions
+10
View File
@@ -40,6 +40,16 @@ tasks:
cmds:
- golangci-lint run
gate:
desc: 'Детерминированный гейт ревью: build/vet/lint/test/race/покрытие диффа/миграции/секреты. BASE=<rev> — база диффа'
cmds:
- bash scripts/gate.sh {{.BASE}}
review:context:
desc: 'Вход для архитектурного прохода ревью: пакеты, граф зависимостей, публичная поверхность, инвентарь концепций'
cmds:
- bash scripts/review-context.sh
tidy:
desc: go mod tidy
cmds:
+112
View File
@@ -0,0 +1,112 @@
#!/usr/bin/env python3
"""Покрытие изменённых строк тестами.
Складывает `go test -coverprofile` и `git diff -U0 <base>`: показывает, какие
изменённые исполняемые строки не покрыты ни одним тестом. Общий процент по
пакету бесполезен для ревью — важно, покрыт ли именно новый код.
Использование: scripts/diff-coverage.py <coverprofile> <base-rev>
"""
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())
+147
View File
@@ -0,0 +1,147 @@
#!/usr/bin/env bash
# Детерминированный гейт ревью: прогоняет всё, у чего есть объективный оракул,
# и печатает сводку. В отличие от `task test`/`task lint` не останавливается на
# первом отказе — ревьюверу нужна полная картина, а не первая упавшая команда.
#
# Использование: scripts/gate.sh [<base-rev>]
# 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() { # <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
# --- Уязвимости зависимостей ---
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 "гейт зелёный (пропущенные шаги см. в сводке — они идут в границы покрытия)"
+86
View File
@@ -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 '```'