diff --git a/.claude/agents/jellybit-review-gate.md b/.claude/agents/jellybit-review-gate.md index 7e94851..9434103 100644 --- a/.claude/agents/jellybit-review-gate.md +++ b/.claude/agents/jellybit-review-gate.md @@ -18,7 +18,7 @@ color: red 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`; подробности — в `tmp/gate/<шаг>.log`. Краснит гейт только `FAIL`. 3. По каждому `FAIL` открой лог и прочитай **реальную** причину. Не пересказывай @@ -44,7 +44,9 @@ color: red него будут ссылаться как на доказательство. - **`SKIP` любого шага** — идёт в границы покрытия дословно, с причиной. Молча пропущенная проверка — это ложное ощущение проверенности, ровно то, ради чего - гейт и заводился. + гейт и заводился. Различай две причины: «код не трогали» — корректный пропуск + (шаги выбираются по изменённым файлам), а «инструмент не установлен» или «не + отработал» — настоящая дыра, и её надо назвать в отчёте. - **`WARN` от `govulncheck`** — гейт не краснеет, но находка нужна. Открой `tmp/gate/govulncheck.log` и посмотри трассы вызовов: уязвимость, приехавшая с зависимостью **этого** change, — `major`; уязвимость в стандартной библиотеке diff --git a/.claude/skills/review-pipeline/SKILL.md b/.claude/skills/review-pipeline/SKILL.md index 9059263..cee0999 100644 --- a/.claude/skills/review-pipeline/SKILL.md +++ b/.claude/skills/review-pipeline/SKILL.md @@ -63,6 +63,10 @@ description: Конвейер ревью изменений jellybit — дет верификация**: изменённые строки без покрытия, конкурентность без теста с параллельным доступом, флаки-тест (не ниже `major`), недоступный инструмент. +Шаги выбираются по изменённым файлам: правка документации не гоняет тесты, +линтеры и `-race`. Пропуск при этом не молчит — он виден в сводке с причиной и +уезжает в границы покрытия, как и любой другой `SKIP`. + ## Стадия 1 — Conformance (обязательна во всех профилях) Два applicative-прохода: оба применяют **записанный** критерий, оба дешёвые, diff --git a/.claude/skills/review-pipeline/references/migration-2026-07.md b/.claude/skills/review-pipeline/references/migration-2026-07.md index ac27f48..37d11b4 100644 --- a/.claude/skills/review-pipeline/references/migration-2026-07.md +++ b/.claude/skills/review-pipeline/references/migration-2026-07.md @@ -49,7 +49,7 @@ - `jellybit-review-code` **не запускался ни в одном профиле** — charter обещал «проход профиля `quick`», а `quick` состоял из стадий 0, 1, 5. Проход, который нельзя запустить, нельзя и откалибровать. Включён стадией 1. -- `scripts/review-context.sh` выгружал `go doc -short` по всему модулю — 264 +- `review-context` выгружал `go doc -short` по всему модулю — 264 строки из 458. Убрано: граф зависимостей, который иначе не восстановить, — это 21 строка, а публичную поверхность агент вытянет `go doc` сам по нужному месту. - Из `calibration.md` убрана секция «дополнительных метрик» (precision, @@ -78,7 +78,7 @@ | матчинг ошибки по тексту сообщения | `internal/archrules` | errors.md | | `AUTOINCREMENT`, `DEFAULT (datetime('now'))` в новых миграциях | `internal/archrules` | database.md | | транспорты не знают друг о друге, ядро не знает о транспортах | `internal/archrules` | CLAUDE.md (осталась одна строка принципа) | -| ER-схема обновлена вместе с миграцией | `scripts/gate.sh` (по диффу) | — | +| ER-схема обновлена вместе с миграцией | `scripts/gate.py` (по диффу) | — | Правки кода под новые правила: `logging.StartCall` как единая точка отсчёта длительности внешних вызовов, `store.Now` вместо `time.Now` в `httpapi` и diff --git a/Taskfile.yml b/Taskfile.yml index 96ef8b1..d1d9102 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -44,12 +44,12 @@ tasks: gate: desc: 'Детерминированный гейт ревью: build/vet/lint/test/race/покрытие диффа/миграции/секреты. BASE= — база диффа' cmds: - - bash scripts/gate.sh {{.BASE}} + - python3 scripts/gate.py {{.BASE}} review:context: desc: 'Вход для архитектурного прохода ревью: пакеты, граф зависимостей, инвентарь концепций' cmds: - - bash scripts/review-context.sh + - python3 scripts/review-context.py tidy: desc: go mod tidy diff --git a/scripts/gate.py b/scripts/gate.py new file mode 100755 index 0000000..9971ef4 --- /dev/null +++ b/scripts/gate.py @@ -0,0 +1,226 @@ +#!/usr/bin/env python3 +"""Детерминированный гейт ревью. + +Прогоняет всё, у чего есть объективный оракул, и печатает сводку. В отличие от +`task test`/`task lint` не останавливается на первом отказе — ревьюверу нужна +полная картина, а не первая упавшая команда. + +Использование: scripts/gate.py [] + 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/.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()) diff --git a/scripts/gate.sh b/scripts/gate.sh deleted file mode 100755 index e2aa4cd..0000000 --- a/scripts/gate.sh +++ /dev/null @@ -1,157 +0,0 @@ -#!/usr/bin/env bash -# Детерминированный гейт ревью: прогоняет всё, у чего есть объективный оракул, -# и печатает сводку. В отличие от `task test`/`task lint` не останавливается на -# первом отказе — ревьюверу нужна полная картина, а не первая упавшая команда. -# -# Использование: scripts/gate.sh [] -# 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() { # - 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 - -# --- Уязвимости зависимостей --- -# Не блокирует: находка тут — состояние зависимостей и тулчейна, а не диффа. -# Исключение (уязвимость приехала с новой зависимостью в этом 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 см. в сводке — они идут в находки и в границы покрытия)" diff --git a/scripts/review-context.py b/scripts/review-context.py new file mode 100755 index 0000000..4041843 --- /dev/null +++ b/scripts/review-context.py @@ -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()) diff --git a/scripts/review-context.sh b/scripts/review-context.sh deleted file mode 100755 index f634590..0000000 --- a/scripts/review-context.sh +++ /dev/null @@ -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 '```'