#!/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())