- сервис поднимается через `task up`/`task restart`, данные вынесены в ./data и переживают пересборку — поток с телефона не прерывается - `task gate` гоняет build/vet/lint/test/race/покрытие диффа/миграции, плюс два шага под healthlog: данные о здоровье в индексе и синхронность образцов конфига - `task review:context` собирает вход для архитектурного прохода ревью
119 lines
5.5 KiB
Python
Executable File
119 lines
5.5 KiB
Python
Executable File
#!/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())
|