добавлено окружение разработки: контейнер и гейт ревью
- сервис поднимается через `task up`/`task restart`, данные вынесены в ./data и переживают пересборку — поток с телефона не прерывается - `task gate` гоняет build/vet/lint/test/race/покрытие диффа/миграции, плюс два шага под healthlog: данные о здоровье в индексе и синхронность образцов конфига - `task review:context` собирает вход для архитектурного прохода ревью
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
# В контекст сборки едет ровно один файл — собранный бинарь (см. Dockerfile).
|
||||||
|
# Всё остальное исключаем: данные измеряются десятками мегабайт и в образе не
|
||||||
|
# нужны, а лишний контекст замедляет каждую пересборку.
|
||||||
|
*
|
||||||
|
!healthlog
|
||||||
+6
-2
@@ -1,12 +1,16 @@
|
|||||||
# Сборка
|
# Сборка
|
||||||
/healthlog
|
/healthlog
|
||||||
|
|
||||||
# Реальный конфиг (токены), локальная БД и сырой архив
|
# Реальный конфиг с токенами. Токенов не содержит и потому коммитится:
|
||||||
|
# config.example.toml (образец) и config.docker.toml (локальный контейнер).
|
||||||
/config.toml
|
/config.toml
|
||||||
|
|
||||||
|
# Данные: база и сырой архив. Один каталог, потому что он же — том контейнера
|
||||||
|
# (см. docker-compose.yml), и потерять его нельзя.
|
||||||
|
/data/
|
||||||
*.db
|
*.db
|
||||||
*.db-wal
|
*.db-wal
|
||||||
*.db-shm
|
*.db-shm
|
||||||
/raw/
|
|
||||||
|
|
||||||
# Временные файлы
|
# Временные файлы
|
||||||
/tmp/
|
/tmp/
|
||||||
|
|||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
# Упаковка готового статического бинаря в минимальный образ.
|
||||||
|
#
|
||||||
|
# Бинарь собирается СНАРУЖИ, а не внутри образа:
|
||||||
|
# CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o healthlog ./cmd/healthlog
|
||||||
|
# Так пересборка идёт по горячему кэшу Go на хосте — секунды вместо минут,
|
||||||
|
# что важно при частом цикле «поправил → перезапустил». Готовит бинарь
|
||||||
|
# `task build`, от которой зависит `task up`.
|
||||||
|
#
|
||||||
|
# distroless/static: без shell и пакетного менеджера, только CA-сертификаты.
|
||||||
|
# Пользователь задаётся в compose (user: "1000:1000") — чтобы файлы в томе
|
||||||
|
# принадлежали хозяину каталога, а не root.
|
||||||
|
FROM gcr.io/distroless/static-debian12
|
||||||
|
|
||||||
|
COPY healthlog /usr/local/bin/healthlog
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# В distroless нет ни shell, ни curl — проверку делает сам бинарь, порт берёт
|
||||||
|
# из конфига. Путь к конфигу задаём явно: умолчание загрузчика — config.toml
|
||||||
|
# в рабочей директории, а конфиг смонтирован в /config.
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||||
|
CMD ["/usr/local/bin/healthlog", "healthcheck", "--config", "/config/config.toml"]
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/local/bin/healthlog", "serve", "--config", "/config/config.toml"]
|
||||||
@@ -38,6 +38,61 @@ tasks:
|
|||||||
cmds:
|
cmds:
|
||||||
- golangci-lint run
|
- golangci-lint run
|
||||||
|
|
||||||
|
up:
|
||||||
|
desc: 'Собрать и поднять сервис в контейнере (данные в ./data переживают пересборку)'
|
||||||
|
deps: [build]
|
||||||
|
cmds:
|
||||||
|
- docker compose up -d --build
|
||||||
|
- task: wait
|
||||||
|
|
||||||
|
down:
|
||||||
|
desc: Остановить контейнер
|
||||||
|
cmds:
|
||||||
|
- docker compose down
|
||||||
|
|
||||||
|
restart:
|
||||||
|
desc: 'Пересобрать бинарь и перезапустить сервис — основной цикл разработки'
|
||||||
|
deps: [build]
|
||||||
|
cmds:
|
||||||
|
- docker compose up -d --build
|
||||||
|
- task: wait
|
||||||
|
|
||||||
|
wait:
|
||||||
|
desc: 'Дождаться, пока сервис ответит на /healthz'
|
||||||
|
internal: true
|
||||||
|
cmds:
|
||||||
|
- |
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
if curl -fsS --max-time 2 http://127.0.0.1:8080/healthz >/dev/null 2>&1; then
|
||||||
|
echo "healthlog отвечает на :8080"; exit 0
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
echo "healthlog не поднялся за 30 секунд, смотри: task logs" >&2
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
logs:
|
||||||
|
desc: 'Логи сервиса (LINES=N — сколько строк, по умолчанию 50)'
|
||||||
|
vars:
|
||||||
|
LINES: '{{.LINES | default "50"}}'
|
||||||
|
cmds:
|
||||||
|
- docker compose logs --tail {{.LINES}} healthlog
|
||||||
|
|
||||||
|
ps:
|
||||||
|
desc: Состояние контейнера
|
||||||
|
cmds:
|
||||||
|
- docker compose ps
|
||||||
|
|
||||||
|
gate:
|
||||||
|
desc: 'Детерминированный гейт ревью: build/vet/lint/test/race/покрытие диффа/миграции/секреты. BASE=<rev> — база диффа'
|
||||||
|
cmds:
|
||||||
|
- python3 scripts/gate.py {{.BASE}}
|
||||||
|
|
||||||
|
review:context:
|
||||||
|
desc: 'Вход для архитектурного прохода ревью: пакеты, граф зависимостей, инвентарь концепций'
|
||||||
|
cmds:
|
||||||
|
- python3 scripts/review-context.py
|
||||||
|
|
||||||
tidy:
|
tidy:
|
||||||
desc: go mod tidy
|
desc: go mod tidy
|
||||||
cmds:
|
cmds:
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Конфигурация локального запуска в контейнере (`task up`).
|
||||||
|
#
|
||||||
|
# ВНИМАНИЕ: этот файл коммитится. Токенов в нём быть не должно.
|
||||||
|
# Сейчас проверка токенов выключена сознательно — сервис работает только в
|
||||||
|
# доверенной локальной сети, телефон шлёт на IP хоста. Перед выездом на
|
||||||
|
# rivendell токены переезжают в отдельный несохраняемый файл, см. задачу
|
||||||
|
# беклога «Управление секретами и токенами».
|
||||||
|
|
||||||
|
[server]
|
||||||
|
addr = ":8080"
|
||||||
|
read_timeout = "5m" # экспорт истории — десятки мегабайт, бывает медленно
|
||||||
|
write_timeout = "30s"
|
||||||
|
|
||||||
|
[auth]
|
||||||
|
write_tokens = [] # ПУСТО = проверка выключена, см. предупреждение выше
|
||||||
|
read_tokens = []
|
||||||
|
|
||||||
|
[storage]
|
||||||
|
# Пути внутри контейнера. Снаружи это каталог ./data, смонтированный в /data
|
||||||
|
# (см. docker-compose.yml): база и сырой архив переживают пересборку образа.
|
||||||
|
db_path = "/data/healthlog.db"
|
||||||
|
archive_dir = "/data/raw"
|
||||||
|
|
||||||
|
[ingest]
|
||||||
|
max_body_mb = 64
|
||||||
|
|
||||||
|
[log]
|
||||||
|
level = "info"
|
||||||
|
format = "json"
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Локальный запуск healthlog в контейнере.
|
||||||
|
#
|
||||||
|
# Зачем контейнер на этапе разработки: телефон шлёт данные непрерывно, и
|
||||||
|
# сервис должен переживать пересборку без ручного вмешательства. `task up`
|
||||||
|
# собирает бинарь и поднимает сервис, `task restart` перезапускает его,
|
||||||
|
# `task logs` показывает поток. Данные лежат в ./data и переживают всё.
|
||||||
|
#
|
||||||
|
# Наружу открыт порт 8080 на всех интерфейсах — иначе телефон из локальной
|
||||||
|
# сети не достучится. Токенов нет: сеть доверенная, см. config.docker.toml.
|
||||||
|
|
||||||
|
services:
|
||||||
|
healthlog:
|
||||||
|
image: healthlog:dev
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: healthlog
|
||||||
|
# Тот же uid, что у хозяина каталога: файлы в ./data остаются нашими, а не
|
||||||
|
# root, иначе их не прочитать скриптами разведки с хоста.
|
||||||
|
user: "1000:1000"
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
- ./config.docker.toml:/config/config.toml:ro
|
||||||
|
- ./data:/data
|
||||||
|
restart: unless-stopped
|
||||||
|
stop_grace_period: 30s
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
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
+250
@@ -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())
|
||||||
Executable
+118
@@ -0,0 +1,118 @@
|
|||||||
|
#!/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."""
|
||||||
|
base = Path(root)
|
||||||
|
if not base.exists():
|
||||||
|
return []
|
||||||
|
re_ = re.compile(pattern)
|
||||||
|
found = set()
|
||||||
|
for path in sorted(base.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 listdir(root: str) -> list[str]:
|
||||||
|
base = Path(root)
|
||||||
|
return sorted(p.name for p in base.iterdir()) if base.is_dir() else []
|
||||||
|
|
||||||
|
|
||||||
|
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/config", r'toml:"'))
|
||||||
|
block("Миграции (порядок = порядок эволюции схемы)",
|
||||||
|
listdir("internal/store/migrations"))
|
||||||
|
block("Маршруты HTTP", scan("internal/httpapi", r'r\.(Get|Post|Put|Delete|Route|Mount)\('))
|
||||||
|
block("Слои гранулярности и прочие перечисления домена",
|
||||||
|
scan("internal", r'^\s*Layer\w+\s+\w*Layer\s*=|^const .*Layer'))
|
||||||
|
block("Capabilities OpenSpec", listdir("openspec/specs"))
|
||||||
|
|
||||||
|
print("## Что держать в голове\n")
|
||||||
|
print("""healthlog — хранилище, а не аналитика. Инварианты, которые архитектурный
|
||||||
|
проход обязан защищать (подробно — `CLAUDE.md` и `docs/architecture.md`):
|
||||||
|
|
||||||
|
- точки хранятся дословно; всё, что теряет содержимое точки, — находка;
|
||||||
|
- идентичность по координатам (`метрика + слой + метка`), `source` в ключ
|
||||||
|
не входит;
|
||||||
|
- агрегации при записи нет; свёртка живёт только в ответе и только с
|
||||||
|
измеренным родом метрики;
|
||||||
|
- нижний слой HAE не суммируется никогда — это интерполяция, а не сэмплы;
|
||||||
|
- секреты и тела запросов не попадают в логи: данные о здоровье
|
||||||
|
чувствительнее токенов.
|
||||||
|
""")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Reference in New Issue
Block a user