regress.sh — один вход: preflight (main = origin/main, чистые, нет чужих потребителей NATS), venv, lint+unit всех репозиториев и docs make check, фронт в чистой копии (npm ci --dry-run как проверка lock, установка без lock), интеграционные с циклом миграций, реальные данные парсера, образы, e2e ядра, изолированный стенд с нуля (compose-проект arm-ka-regress на своих портах) со сценарием по API, UI-смоук Playwright, парсер по doc-bank. Не останавливается на первой ошибке; итог — summary.md с SHA и статусом шагов. Контрольные цифры — expected.json (qa-guide §7.1–7.3, прогоны 08.10/14.10, golden шахматки); снимки свода, шахматки и форм сравниваются с прошлым прогоном. Ссылки из dev/checks.md и чек-листа синхронизации контура. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
88 lines
4.6 KiB
Python
88 lines
4.6 KiB
Python
"""Итог регрессии: summary.md из results.tsv, shas.tsv и логов шагов (вызывает regress.sh).
|
||
|
||
Аргументы: каталог прогона, список этапов через пробел (порядок в таблице). Для логов pytest — последняя строка-итог
|
||
(«N passed, M skipped …») и число пропусков из-за отсутствия docs/fixtures: зелёный шаг с такими пропусками не доказывает
|
||
сверку контракта и прохождение на реальных данных (dev/checks.md, «Внешние ресурсы»).
|
||
"""
|
||
|
||
import json
|
||
import re
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
PYTEST_TAIL = re.compile(r"=+ (.*\b(passed|failed|error|skipped|deselected)\b.*) in [\d.]+s")
|
||
# причины пропуска «нет соседнего репозитория» в сервисах (dev/checks.md)
|
||
MISSING_RES = re.compile(r"SKIPPED.*(ARM_KA_FIXTURES|ARM_KA_DOCS|fixtures недоступ|docs недоступ|нет \.\./(docs|fixtures))")
|
||
|
||
|
||
def pytest_info(log: Path) -> str:
|
||
try:
|
||
text = log.read_text(errors="replace")
|
||
except OSError:
|
||
return ""
|
||
tails = [m.group(1) for m in PYTEST_TAIL.finditer(text)]
|
||
missing = len(MISSING_RES.findall(text))
|
||
parts = []
|
||
if tails:
|
||
parts.append(tails[-1] if len(tails) == 1 else " | ".join(tails))
|
||
if missing:
|
||
parts.append(f"⚠ пропусков без docs/fixtures: {missing}")
|
||
return "; ".join(parts)
|
||
|
||
|
||
def first_error(log: Path) -> str:
|
||
"""Короткая подсказка для красного шага: первая строка FAILED/ERROR/КРАСНЫЙ/Error из лога."""
|
||
try:
|
||
lines = log.read_text(errors="replace").splitlines()
|
||
except OSError:
|
||
return ""
|
||
for pat in (r"^FAILED |^ERROR ", r"FAIL|КРАСН|✗", r"[Ee]rror|ошибк"):
|
||
for ln in lines:
|
||
if re.search(pat, ln):
|
||
return ln.strip()[:160]
|
||
return lines[-1].strip()[:160] if lines else ""
|
||
|
||
|
||
def main() -> None:
|
||
out, stages = Path(sys.argv[1]), sys.argv[2].split()
|
||
rows = [ln.split("\t") for ln in (out / "results.tsv").read_text().splitlines() if ln.strip()]
|
||
shas = [ln.split("\t") for ln in (out / "shas.tsv").read_text().splitlines()] if (out / "shas.tsv").exists() else []
|
||
red = [r for r in rows if r[2] != "0"]
|
||
md = [f"# Регрессия {out.name}", ""]
|
||
md.append(f"**Итог: {'ЗЕЛЁНАЯ' if not red else f'КРАСНАЯ — {len(red)} из {len(rows)} шагов'}**")
|
||
if (out / "allow-dirty").exists():
|
||
md.append("")
|
||
md.append("⚠ Прогон с `--allow-dirty`: preflight не пройден (не main, не origin/main или есть правки) — см. этап preflight.")
|
||
md += ["", "## Репозитории", "", "| Репозиторий | Ветка | SHA | Последний коммит |", "|---|---|---|---|"]
|
||
md += [f"| {r} | {b} | `{h[:10]}` | {s} |" for r, b, h, s in shas]
|
||
md += ["", "## Шаги", "", "| Этап | Шаг | Статус | Время, с | Детали |", "|---|---|---|---|---|"]
|
||
order = {s: i for i, s in enumerate(stages)}
|
||
for stage, name, rc, sec, log in sorted(rows, key=lambda r: order.get(r[0], 99)):
|
||
logp = Path(log)
|
||
detail = pytest_info(logp)
|
||
if rc != "0":
|
||
detail = "; ".join(x for x in (detail, first_error(logp)) if x)
|
||
status = "ok" if rc == "0" else f"**КРАСНЫЙ** ({rc})"
|
||
md.append(f"| {stage} | {name} | {status} | {sec} | {detail.replace('|', '/')} |")
|
||
stand = out / "stand" / "report.json"
|
||
if stand.exists():
|
||
rep = json.loads(stand.read_text())
|
||
md += ["", "## Стенд: сценарий по API", ""]
|
||
red = [c for c in rep["checks"] if not c["ok"]]
|
||
md.append(f"Проверок {len(rep['checks'])}, красных {len(red)}.")
|
||
md.append("")
|
||
md += [f"- {'✓' if c['ok'] else '✗'} {c['name']}" + (f": {c['detail']}" if c["detail"] else "") for c in rep["checks"]]
|
||
if rep.get("baseline"):
|
||
md += ["", f"Сравнение снимков со стендом прошлого прогона `{rep['baseline']}`: расхождений {len(rep['diffs'])}."]
|
||
md += [f"- `{d['file']}`: {d['detail']}" for d in rep["diffs"]]
|
||
bank = out / "bank" / "summary.md"
|
||
if bank.exists():
|
||
md += ["", bank.read_text().strip()]
|
||
md += ["", f"Логи: `{out / 'logs'}`"]
|
||
(out / "summary.md").write_text("\n".join(md) + "\n")
|
||
print("\n".join(md))
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|