/
kochenov
/
universal-modular
Обзор
Документация
Войти
/
kochenov
/
universal-modular
Код
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
scripts/verify_step_completion.py
378 строк
16 KB
Kochenov Dmitry
feat(foundation): Шаг 1.5 — Создать pyproject.toml + ruff.toml + pre-commit конфиг
10 июл 2026, 16:06
10 июл 2026, 16:06
e52daa8
Код
Авторство
О чём код?
#!/usr/bin/env python3 """ Многосторонний верификатор завершения шага (аудит v8). Запускается в фазе TESTING как дополнительная проверка после основного verify. Проверяет: 1. Доступность документации для пользователя (если шаг создал docs_target) 2. Целостность ссылок в документации 3. Существование обязательных файлов шага 4. Логирование всех проверок через agent_log.py Запуск: uv run python scripts/verify_step_completion.py <step_id> uv run python scripts/verify_step_completion.py 1.7 Exit codes: 0 — все проверки прошли 1 — есть критичные ошибки 2 — шаг не найден """ import argparse import re import subprocess import sys from pathlib import Path import yaml BASE = Path(__file__).parent.parent PLANS_DIR = BASE / 'plans' # Импорт функций из agent_log (как модуль) sys.path.insert(0, str(BASE / 'scripts')) from agent_log import add_entry def find_step(step_id: str) -> tuple[dict, str] | None: """Найти шаг в YAML. Вернуть (step_dict, yaml_filename).""" for yfile in sorted(PLANS_DIR.glob('*.yaml')): if yfile.name.startswith('99'): continue try: data = yaml.safe_load(yfile.read_text(encoding='utf-8')) except Exception: continue for step in data.get('steps', []): if step.get('id') == step_id: return step, yfile.name return None def check_docs_target_exists(step: dict) -> tuple[bool, str]: """Проверить, что docs_target существует.""" docs_target = step.get('docs_target', '') if not docs_target: return False, 'docs_target не указан в YAML' full = BASE / docs_target if not full.exists(): return False, f'файл {docs_target} не существует' if full.stat().st_size == 0: return False, f'файл {docs_target} пустой' return True, f'файл {docs_target} существует ({full.stat().st_size} байт)' def check_docs_target_phase2(step: dict) -> tuple[bool, str]: """Проверить, что docs_target в Phase 2 (для завершённого шага).""" docs_target = step.get('docs_target', '') if not docs_target: return False, 'docs_target не указан' full = BASE / docs_target if not full.exists(): return False, f'файл {docs_target} не существует' content = full.read_text(encoding='utf-8') if '> Phase 1 — инструкция для AI-агента' in content and '> Обучающая документация' not in content: return False, 'документация в Phase 1 (Phase 2 не применена)' if '> Обучающая документация' not in content: return False, f'нет маркера Phase 2 в {docs_target}' return True, 'Phase 2 применена' def check_required_sections(step: dict) -> tuple[bool, list[str]]: """Проверить обязательные секции Phase 2.""" docs_target = step.get('docs_target', '') if not docs_target: return False, ['docs_target не указан'] full = BASE / docs_target if not full.exists(): return False, [f'{docs_target} не существует'] content = full.read_text(encoding='utf-8') required = [ '## Что вы получите в конце шага', '## Предварительные требования', '## Почему именно так', '## Пошаговая инструкция', '## Как проверить', '## Типичные ошибки и их решения', '## Что дальше', '## История действий агента', '## Реестр файлов шага', '## Реестр тестов шага', '## VERIFIED_CHECKLIST', ] missing = [s for s in required if s not in content] if missing: return False, missing return True, [] def check_verified_checklist_closed(step: dict) -> tuple[bool, list[str]]: """Проверить, что все пункты VERIFIED_CHECKLIST закрыты [x].""" docs_target = step.get('docs_target', '') if not docs_target: return False, ['docs_target не указан'] full = BASE / docs_target if not full.exists(): return False, [f'{docs_target} не существует'] content = full.read_text(encoding='utf-8') # Найти секцию VERIFIED_CHECKLIST match = re.search(r'## VERIFIED_CHECKLIST\s*\n(.*?)(?=\n## |\Z)', content, re.DOTALL) if not match: return False, ['секция VERIFIED_CHECKLIST не найдена'] section = match.group(1) # Найти незакрытые пункты open_items = re.findall(r'^- \[ \]\s+(.+)$', section, re.MULTILINE) if open_items: return False, open_items return True, [] def check_history_min_records(step: dict) -> tuple[bool, str]: """Проверить, что в Истории действий минимум 5 записей.""" docs_target = step.get('docs_target', '') if not docs_target: return False, 'docs_target не указан' full = BASE / docs_target if not full.exists(): return False, f'{docs_target} не существует' content = full.read_text(encoding='utf-8') # Найти секцию История действий match = re.search(r'## История действий агента\s*\n(.*?)(?=\n## |\Z)', content, re.DOTALL) if not match: return False, 'секция История действий не найдена' section = match.group(1) # Подсчитать строки таблицы (исключая заголовок и разделитель) rows = re.findall(r'^\|.*\|$', section, re.MULTILINE) # Убрать заголовок и разделитель data_rows = [r for r in rows if not re.match(r'^\|[\s\-:|]+\|$', r) and 'Время' not in r] if len(data_rows) < 5: return False, f'записей: {len(data_rows)} (нужно минимум 5)' return True, f'записей: {len(data_rows)}' def check_test_registry_has_pass(step: dict) -> tuple[bool, str]: """Проверить, что Реестр тестов содержит хотя бы одну запись PASS.""" docs_target = step.get('docs_target', '') if not docs_target: return False, 'docs_target не указан' full = BASE / docs_target if not full.exists(): return False, f'{docs_target} не существует' content = full.read_text(encoding='utf-8') match = re.search(r'## Реестр тестов шага\s*\n(.*?)(?=\n## |\Z)', content, re.DOTALL) if not match: return False, 'секция Реестр тестов не найдена' section = match.group(1) if 'PASS' not in section and 'PASSED' not in section: return False, 'нет записей PASS/PASSED в реестре тестов' return True, 'есть PASS/PASSED записи' def check_verify_command(step: dict) -> tuple[bool, str]: """Запустить verify-команду из YAML.""" verify = step.get('verify', []) if not verify: return True, 'verify не указан (пропуск)' # для user_action cmd = verify[0] try: result = subprocess.run( cmd, shell=True, capture_output=True, text=True, cwd=BASE, timeout=120, ) if result.returncode == 0: return True, f'verity PASS: {cmd}' return False, f'verify FAIL (exit {result.returncode}):\n cmd: {cmd}\n stderr: {result.stderr[:500]}' except subprocess.TimeoutExpired: return False, f'verify TIMEOUT (120s): {cmd}' except Exception as e: return False, f'verify ERROR: {e}' def check_docs_accessible_to_user(step: dict) -> tuple[bool, str]: """Проверить, что документация доступна пользователю. Для шагов, создающих docs_target — это означает: 1. Файл существует и читается 2. Файл валидный markdown (баланс бэктиков, таблицы) 3. Файл доступен через VitePress (если docs/.vitepress/ есть) — это проверяется через npm run docs:build """ docs_target = step.get('docs_target', '') if not docs_target or not (BASE / docs_target).exists(): return False, f'docs_target {docs_target} не существует' full = BASE / docs_target content = full.read_text(encoding='utf-8') # Проверка балансировки бэктиков backticks = content.count('```') if backticks % 2 != 0: return False, f'незакрытый код-блок: {backticks} тройных бэктиков (нечётное)' # Проверка H1 has_h1 = bool(re.search(r'^# .+', content, re.MULTILINE)) if not has_h1: return False, 'нет H1 заголовка' # Проверка через validate_md.py (если существует) validate_md = BASE / 'scripts' / 'validate_md.py' if validate_md.exists(): try: result = subprocess.run( ['python3', str(validate_md), str(full)], capture_output=True, text=True, cwd=BASE, timeout=30, ) if result.returncode != 0: return False, f'validate_md.py FAIL:\n{result.stdout[:500]}' except Exception as e: return False, f'validate_md.py ERROR: {e}' return True, 'документация валидна и доступна' def check_no_placeholders(step: dict) -> tuple[bool, list[str]]: """Проверить, что в docs_target нет незаменённых плейсхолдеров.""" docs_target = step.get('docs_target', '') if not docs_target: return True, [] full = BASE / docs_target if not full.exists(): return True, [] content = full.read_text(encoding='utf-8') # Найти <...> вне код-блоков in_code = False placeholders = [] for i, line in enumerate(content.split('\n'), 1): if line.count('```') % 2 == 1: in_code = not in_code continue if in_code: continue # Найти <...> кроме HTML-тегов for m in re.finditer(r'<([^>]+)>', line): inner = m.group(1).strip().lower() if inner in ['br', 'sup', 'sub', 'b', '/b', 'i', '/i', 'strong', '/strong', 'em', '/em', 'code', '/code', 'hr', '/hr']: continue placeholders.append(f'строка {i}: <{m.group(1)}>') if placeholders: return False, placeholders return True, [] def main(): parser = argparse.ArgumentParser(description='Многосторонний верификатор шага') parser.add_argument('step_id', help='ID шага, например 1.7') parser.add_argument('--strict', action='store_true', help='Строгий режим: warning = error') args = parser.parse_args() step_info = find_step(args.step_id) if step_info is None: print(f'ERROR: шаг {args.step_id} не найден') sys.exit(2) step, yfile = step_info step_type = step.get('step_type', '') print(f'\n{"=" * 70}') print(f'Многосторонняя проверка шага {args.step_id} ({step_type})') print(f'YAML: {yfile}') print(f'{"=" * 70}\n') # Для user_action — другие проверки if step_type == 'user_action': print('Шаг типа user_action — упрощённая проверка (только docs_target существует)') ok, msg = check_docs_target_exists(step) print(f' [{"✓" if ok else "✗"}] docs_target exists: {msg}') if ok: add_entry(f'Step {args.step_id} verification PASSED (user_action)', 'test', step=args.step_id) sys.exit(0) else: add_entry(f'Step {args.step_id} verification FAILED: {msg}', 'error', step=args.step_id) sys.exit(1) # Полный набор проверок для обычных шагов checks = [ ('docs_target exists', lambda: check_docs_target_exists(step)), ('Phase 2 applied', lambda: check_docs_target_phase2(step)), ('required sections', lambda: check_required_sections(step)), ('VERIFIED_CHECKLIST closed', lambda: check_verified_checklist_closed(step)), ('history ≥ 5 records', lambda: check_history_min_records(step)), ('test registry has PASS', lambda: check_test_registry_has_pass(step)), ('verify command passes', lambda: check_verify_command(step)), ('docs accessible to user', lambda: check_docs_accessible_to_user(step)), ('no placeholders', lambda: check_no_placeholders(step)), ] passed = 0 failed = 0 failures = [] for name, check_fn in checks: try: result = check_fn() if isinstance(result, tuple) and len(result) == 2: ok, details = result else: ok, details = result, '' if ok: print(f' [✓] {name}: {details if isinstance(details, str) else "OK"}') passed += 1 add_entry(f'Check "{name}" PASSED: {details if isinstance(details, str) else ""}', 'test', step=args.step_id, details={'check': name, 'result': 'pass'}) else: if isinstance(details, list): details_str = '; '.join(str(d) for d in details[:3]) if len(details) > 3: details_str += f' (и ещё {len(details) - 3})' else: details_str = str(details) print(f' [✗] {name}: {details_str}') failed += 1 failures.append((name, details_str)) add_entry(f'Check "{name}" FAILED: {details_str}', 'error', step=args.step_id, details={'check': name, 'result': 'fail', 'details': details_str}) except Exception as e: print(f' [!] {name}: EXCEPTION {e}') failed += 1 failures.append((name, f'EXCEPTION: {e}')) add_entry(f'Check "{name}" EXCEPTION: {e}', 'error', step=args.step_id, details={'check': name, 'result': 'exception'}) print(f'\n{"=" * 70}') print(f'Итог: {passed} passed, {failed} failed') if failures: print('Неудачные проверки:') for name, msg in failures: print(f' - {name}: {msg}') print(f'{"=" * 70}') if failed > 0: add_entry(f'Step {args.step_id} verification FAILED ({failed} checks)', 'error', step=args.step_id, details={'passed': passed, 'failed': failed, 'failures': [f[0] for f in failures]}) sys.exit(1) else: add_entry(f'Step {args.step_id} verification PASSED ({passed} checks)', 'test', step=args.step_id, details={'passed': passed, 'failed': 0}) sys.exit(0) if __name__ == '__main__': main()