ревью: детерминированный гейт 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:
@@ -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:
|
||||
|
||||
Executable
+112
@@ -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())
|
||||
Executable
+147
@@ -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 "гейт зелёный (пропущенные шаги см. в сводке — они идут в границы покрытия)"
|
||||
Executable
+86
@@ -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 '```'
|
||||
Reference in New Issue
Block a user