/
kochenov
/
universal-modular
Обзор
Документация
Войти
/
kochenov
/
universal-modular
Код
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
scripts/validate_plans.py
268 строк
12 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 """ Dry-run валидатор YAML-планов (I6). Запуск: uv run python scripts/validate_plans.py Exit 0 = VALID, Exit 1 = INVALID. Правила (аудит v4 расширил R13–R21): R01 — title ≤ 60 символов R02 — первый промпт — step-pre-flight.md R03 — step-finalizer.md присутствует R04 — adr-writer.md ДО step-finalizer.md R05 — нет запрещённых полей R06 — уникальные id шагов R07 — уникальные docs_target R08 — ровно 1 verify-команда R09 — verify не кросс-доменная (pytest+k6+npm test) R10 — статические файлы context_to_read существуют R11 — step_type присутствует R12 — step_count совпадает с реальным числом шагов R13 — (v4) все файлы из prompts_to_use существуют R14 — (v4) нет коллизий ADR-номеров (NNNN- в verify) R15 — (v4) step_type=document + code-writer в prompts_to_use → WARN R16 — (v4) requires_approval=true → approval_reason обязателен R17 — (v4) generates_stage_summary=true только на последнем шаге этапа R21 — (v4) якоря в context_to_read (после #) существуют в файле """ import os import re import sys import yaml BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) PLANS_DIR = os.path.join(BASE, 'plans') FORBIDDEN_FIELDS = {'description','result','actions','watch_out_for','doc_sections', 'estimated_tokens','ai_context','prompt_alias','one_liner', 'success_verification','stage_checkpoint','is_stage_end', 'files_modified','actions_split'} issues = [] warnings = [] all_steps = {} all_docs_targets = {} all_adr_numbers = {} # ADR-номер → [(step_id, file)] def issue(rule, msg): issues.append((rule, msg)) def warn(rule, msg): warnings.append((rule, msg)) def extract_adr_numbers(verify_commands): """Извлекает ADR-номера из verify вида test -f .../adr/0001-name.md""" nums = [] for v in verify_commands: if not isinstance(v, str): v = str(v) if v else '' for m in re.finditer(r'adr/(\d{4})-', v): nums.append(int(m.group(1))) return nums def check_anchor_exists(file_path, anchor): """Проверяет, что в file_path есть заголовок, дающий якорь `anchor`. VitePress генерирует якоря из заголовков нижнего регистра с дефисами. """ if not os.path.exists(file_path): return False try: with open(file_path, encoding='utf-8') as f: content = f.read() except Exception: return False # Ищем заголовок markdown: # ... <text> # Якорь — это нижний регистр текста заголовка с заменой пробелов на - target = anchor.lower().replace(' ', '-') for line in content.split('\n'): m = re.match(r'^(#{1,6})\s+(.+?)\s*$', line) if m: heading = m.group(2).lower() # Нормализуем: убираем пунктуацию, заменяем пробелы на - normalized = re.sub(r'[^\w\s-]', '', heading).replace(' ', '-') normalized = re.sub(r'-+', '-', normalized).strip('-') if normalized == target: return True return False for fname in sorted(os.listdir(PLANS_DIR)): if not fname.endswith('.yaml') or fname.startswith('99'): continue # 99-*.yaml — шаблоны (I8) fpath = os.path.join(PLANS_DIR, fname) try: data = yaml.safe_load(open(fpath)) except yaml.YAMLError as e: issue('R00', f'[{fname}]: YAML parse error: {e}') continue # Аудит v7: 00-onboarding.yaml — это этап 0 с user_action шагами. # Пропускаем строгие проверки R02/R03/R07/R08/R11/R13 для user_action. is_onboarding = fname == '00-onboarding.yaml' steps = data.get('steps', []) declared = data.get('ai_ready', {}).get('step_count') if declared and declared != len(steps): issue('R12', f'[{fname}]: step_count={declared} ≠ реально {len(steps)}') for idx, step in enumerate(steps): sid = step.get('id', '?') title = step.get('title', '') prompts = step.get('prompts_to_use', []) verify = step.get('verify', []) ctx = step.get('context_to_read', []) dt = step.get('docs_target', '') step_type = step.get('step_type', '') requires_approval = step.get('requires_approval', False) approval_reason = step.get('approval_reason', '') generates_summary = step.get('generates_stage_summary', False) parallel_group = step.get('parallel_group', '') # R01 title length if len(title) > 60: issue('R01', f'[{fname}] {sid}: title {len(title)} символов: {title}') # R02 pre-flight first — пропускаем для user_action (нет промптов) if not is_onboarding and step_type != 'user_action': if not prompts or 'step-pre-flight' not in prompts[0]: issue('R02', f'[{fname}] {sid}: первый промпт не step-pre-flight.md') # R03 finalizer present — пропускаем для user_action if not is_onboarding and step_type != 'user_action': if not any('step-finalizer' in p for p in prompts): issue('R03', f'[{fname}] {sid}: нет step-finalizer.md') # R04 adr-writer before finalizer if any('adr-writer' in p for p in prompts): adr_i = next(i for i, p in enumerate(prompts) if 'adr-writer' in p) fin_i = next(i for i, p in enumerate(prompts) if 'step-finalizer' in p) if adr_i > fin_i: issue('R04', f'[{fname}] {sid}: adr-writer(pos={adr_i}) ПОСЛЕ finalizer(pos={fin_i})') # R05 no forbidden fields for ff in FORBIDDEN_FIELDS: if ff in step: issue('R05', f'[{fname}] {sid}: запрещённое поле \'{ff}\'') # R06 no duplicate step ids if sid in all_steps: issue('R06', f'Дубликат id \'{sid}\' в {fname} и {all_steps[sid]}') all_steps[sid] = fname # R07 no duplicate docs_target if dt: if dt in all_docs_targets: issue('R07', f'Дубликат docs_target \'{dt}\' в {fname} и {all_docs_targets[dt]}') all_docs_targets[dt] = fname # R08 exactly one verify command — для user_action verify не нужен if step_type != 'user_action': if len(verify) != 1: issue('R08', f'[{fname}] {sid}: verify содержит {len(verify)} команд (ожидается 1)') else: # Аудит v7: для user_action обязательно user_verify if 'user_verify' not in step: issue('R08', f'[{fname}] {sid}: user_action требует поле user_verify (текст проверки для пользователя)') if verify: issue('R08', f'[{fname}] {sid}: user_action не должен иметь verify (используй user_verify)') # R09 no cross-domain verify v = verify[0] if verify else '' if not isinstance(v, str): v = str(v) if v else '' domains = sum([ 'pytest' in v, 'k6 run' in v, 'npm run test' in v or 'npm test' in v, ]) if domains >= 2: issue('R09', f'[{fname}] {sid}: кросс-доменная verify ({v[:60]})') # R10 static context_to_read files exist for ref in ctx: ref_path = ref.split('#')[0] if ref_path.startswith('docs/'): continue # dynamic — skip # STEP_STATE.md — динамический (аудит v4 H15), создаётся на шаге 1.0 if ref_path == 'STEP_STATE.md': continue full = os.path.join(BASE, ref_path) if not os.path.exists(full): issue('R10', f'[{fname}] {sid}: статический файл отсутствует: {ref_path}') # R11 step_type present if 'step_type' not in step: issue('R11', f'[{fname}] {sid}: отсутствует поле step_type') elif step_type == 'user_action': # Аудит v7: для user_action запрещены branch, prompts_to_use if 'branch' in step: issue('R11', f'[{fname}] {sid}: user_action не должен иметь branch (Git ещё не инициализирован)') if step.get('prompts_to_use'): issue('R11', f'[{fname}] {sid}: user_action не должен иметь prompts_to_use (нет субагентов)') # R13 (v4) prompts_to_use files exist — пропускаем для user_action if step_type != 'user_action': for p in prompts: p_path = p.split('#')[0] full = os.path.join(BASE, p_path) if not os.path.exists(full): issue('R13', f'[{fname}] {sid}: промпт отсутствует: {p_path}') # R14 (v4) ADR-number collisions adr_nums = extract_adr_numbers(verify) for n in adr_nums: if n in all_adr_numbers: for (prev_sid, prev_file) in all_adr_numbers[n]: issue('R14', f'[{fname}] {sid}: коллизия ADR-{n:04d} с шагом {prev_sid} в {prev_file}') all_adr_numbers.setdefault(n, []).append((sid, fname)) # R15 (v4) step_type=document + code-writer → WARN # ОТКЛЮЧЕНО в v4.1: после обновления §I2 (аудит v4 H01) комбинация # step_type=document + code-writer в prompts_to_use официально разрешена # (document-шаги используют code-writer в лёгком режиме для генерации md-файлов). # См. .agent/rules/00-agent-protocol.md §I2. # if step_type == 'document' and any('code-writer' in p for p in prompts): # warn('R15', f'[{fname}] {sid}: step_type=document + code-writer в prompts_to_use (см. §I2 — аудит v4 H01)') # R16 (v4) requires_approval → approval_reason if requires_approval and not approval_reason: issue('R16', f'[{fname}] {sid}: requires_approval=true без approval_reason') # R17 (v4) generates_stage_summary только на последнем шаге этапа if generates_summary and idx != len(steps) - 1: issue('R17', f'[{fname}] {sid}: generates_stage_summary=true, но шаг не последний в этапе (позиция {idx+1} из {len(steps)})') # R21 (v4) anchor exists in file for ref in ctx: if '#' in ref: file_part, anchor = ref.split('#', 1) if file_part.startswith('docs/'): continue # dynamic full = os.path.join(BASE, file_part) if os.path.exists(full): if not check_anchor_exists(full, anchor): warn('R21', f'[{fname}] {sid}: якорь #{anchor} не найден в {file_part}') print(f"\n{'='*60}") print(f"Validate Plans — {len(all_steps)} шагов, {len(all_docs_targets)} docs_target") print(f"{'='*60}") if warnings: print(f"\nWARNINGS ({len(warnings)}):") print(f"{'':4}{'Правило':8} {'Описание'}") print(f"{'':4}{'-'*8} {'-'*50}") for rule, msg in sorted(warnings): print(f" ⚠️ {rule:6} {msg}") if issues: print(f"\nERRORS ({len(issues)}):") print(f"{'':4}{'Правило':8} {'Описание'}") print(f"{'':4}{'-'*8} {'-'*50}") for rule, msg in sorted(issues): print(f" ❌ {rule:6} {msg}") print(f"\nИтого: {len(issues)} ошибок, {len(warnings)} предупреждений — INVALID ❌") sys.exit(1) else: if warnings: print(f"\n✅ Все обязательные проверки пройдены — VALID (с {len(warnings)} предупреждениями)") else: print("\n✅ Все проверки пройдены — VALID") sys.exit(0)