/
kochenov
/
uni
Обзор
Документация
Войти
/
kochenov
/
uni
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
main
scripts/validate_plans.py
453 строки
24 KB
Dmitry Kochenov
Initial commit: UMP base structure
16 июл 2026, 21:14
16 июл 2026, 21:14
86b4d0e
Код
Авторство
О чём код?
#!/usr/bin/env python3 """ Dry-run валидатор YAML-планов (I6). Запуск: uv run python scripts/validate_plans.py Exit 0 = VALID, Exit 1 = INVALID. Правила (аудит v4 расширил R13–R21; FIX-4 расширил R10, R15, R28): 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 — (FIX-4.9) статические файлы context_to_read существуют; docs/ пути проверяются против реестра docs_target всех шагов (или должны существовать) R11 — step_type присутствует R12 — step_count совпадает с реальным числом шагов R13 — (v4) все файлы из prompts_to_use существуют R14 — (v4) нет коллизий ADR-номеров (NNNN- в verify) R15 — (FIX-4.10) для не-document/user_action/adr шагов ≥ 5 промптов R16 — (v4) requires_approval=true → approval_reason обязателен R17 — (v4) generates_stage_summary=true только на последнем шаге этапа R21 — (v4) якоря в context_to_read (после #) существуют в файле R28 — (FIX-4.13) id шага должен соответствовать номеру этапа в имени файла R29 — (FIX-V24-3.6) parallel_group — шаги в одной группе не зависят друг от друга через context_to_read (no intra-group cycles) R30 — (аудит v31, P13) cross-check: общее число шагов в plans/completion-strategy.md совпадает с суммой step_count всех YAML-планов; проверка устаревших счётчиков «Foundation 16 шагов» / «140 шагов» в ROADMAP, 00-manifest и др. """ 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 = {} # dt → fname (для R07) all_docs_targets_set = set() # FIX-4.9: множество всех docs_target (для R10) all_adr_numbers = {} # ADR-номер → [(step_id, file)] # FIX-4.9: предобход — собрать все docs_target из всех YAML (для R10) for fname in sorted(os.listdir(PLANS_DIR)): if not fname.endswith('.yaml') or fname.startswith('99'): continue fpath = os.path.join(PLANS_DIR, fname) try: with open(fpath, encoding='utf-8') as f: _data = yaml.safe_load(f) except Exception: continue for _step in (_data or {}).get('steps', []): _dt = _step.get('docs_target') if _dt: all_docs_targets_set.add(_dt) 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 (FIX-4.9: docs/ пути проверяются # против реестра docs_target всех шагов, плюс ADR и реально существующие файлы) for ref in ctx: ref_path = ref.split('#')[0] if ref_path.startswith('docs/'): # FIX-4.9: проверять docs/ пути против реестра docs_target if ref_path in all_docs_targets_set: continue # OK — это docs_target какого-то шага # Также разрешить ссылки на ADR (создаются динамически на adr-шагах) if 'docs/для-разработчиков/adr/' in ref_path: continue # Также разрешить ссылки на docs/руководство/, docs/index.md и т.п. — статические if os.path.exists(os.path.join(BASE, ref_path)): continue # файл уже существует issue('R10', f'[{fname}] {sid}: docs/ путь не является docs_target ни одного шага и не существует: {ref_path}') continue # 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 'prompts_to_use' in step and step['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 (FIX-4.10) минимум 5 промптов, кроме document/user_action/adr # (document-шаги используют 5 промптов без code-writer, adr — с adr-writer; user_action не имеет промптов) if step.get('step_type') not in ('document', 'user_action', 'adr'): if len(prompts) < 5: issue('R15', f'[{fname}] {sid}: только {len(prompts)} промптов (нужно ≥5)') # 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}') # R28 (FIX-4.13) id шага должен соответствовать номеру этапа в имени файла yfiles = [] for fname in sorted(os.listdir(PLANS_DIR)): if not fname.endswith('.yaml') or fname.startswith('99'): continue yfiles.append(os.path.join(PLANS_DIR, fname)) for fpath in yfiles: fname = os.path.basename(fpath) stage_match = re.match(r'^(\d+)-', fname) if not stage_match: continue expected_stage = int(stage_match.group(1)) try: with open(fpath, encoding='utf-8') as f: data = yaml.safe_load(f) except Exception: continue for step in (data or {}).get('steps', []): sid = step.get('id', '') m = re.match(r'^(\d+)\.', str(sid)) if not m: issue('R28', f'[{fname}] {sid}: id не в формате <stage>.<N>') continue actual_stage = int(m.group(1)) if actual_stage != expected_stage: issue('R28', f'[{fname}] {sid}: stage в id ({actual_stage}) не совпадает с номером файла ({expected_stage})') # R29 (FIX-V24-3.6) parallel_group — DEPRECATED в v36 (полный разрыв с Roo/Kilo legacy). # В v35 R29 проверял отсутствие intra-group cycles через context_to_read. # В v36 R29 не выдаёт ошибок (parallel_group безопасно игнорируется). # Новые правила v36: # R32 — informational DEPRECATED warning при обнаружении parallel_group в YAML # R33 — cycle detection через context_to_read (без привязки к parallel_group) # (если шаг A → ctx → шаг B → ctx → шаг A — это cycle, deadlock при последовательном выполнении) parallel_groups = {} # group_name → list of (step_id, docs_target, context_to_read) all_steps_for_cycle = [] # для R33 — собираем ВСЕ шаги, не только с parallel_group for fpath in yfiles: fname = os.path.basename(fpath) try: with open(fpath, encoding='utf-8') as f: data = yaml.safe_load(f) except Exception: continue for step in (data or {}).get('steps', []): # R32 (аудит v36): DEPRECATED warning для parallel_group pg = step.get('parallel_group') if pg: warn('R32', f"[{fname}] {step.get('id')}: поле `parallel_group: {pg}` DEPRECATED (аудит v36). " f"Поле игнорируется. Шаг выполняется последовательно. " f"Рекомендация: удалите `parallel_group` из YAML (через PROTECT-ГЕЙТ, т.к. plans/ — immutable).") parallel_groups.setdefault(pg, []).append({ 'id': step.get('id'), 'docs_target': step.get('docs_target', ''), 'context_to_read': step.get('context_to_read', []), }) # Собираем все шаги для R33 (cycle detection без parallel_group) all_steps_for_cycle.append({ 'id': step.get('id'), 'docs_target': step.get('docs_target', ''), 'context_to_read': step.get('context_to_read', []), }) # R29 — теперь empty (оставлено для совместимости, не выдаёт ничего) # (cycles внутри parallel_group больше не релевантны, т.к. parallel_group deprecated) for group_name, steps_in_group in parallel_groups.items(): pass # no-op — R29 logic moved to R33 # R33 (аудит v36) — cycle detection через context_to_read (без привязки к parallel_group) # Шаги выполняются строго последовательно. Если шаг A → context_to_read → шаг B (docs_target), # а шаг B → context_to_read → шаг A — это cycle. Шаг A не может начаться, пока не завершён B, # а B не может завершиться, пока не начат A. Это deadlock. docs_target_to_step = {s['docs_target']: s['id'] for s in all_steps_for_cycle if s['docs_target']} def has_cycle(start_id, visited, path): """DFS для обнаружения цикла в графе зависимостей context_to_read.""" if start_id in path: return list(path) + [start_id] # возвращаем цикл if start_id in visited: return None visited.add(start_id) path.append(start_id) step = next((s for s in all_steps_for_cycle if s['id'] == start_id), None) if step: for ctx in step.get('context_to_read', []) or []: ctx_path = ctx.split('#')[0] if ctx_path in docs_target_to_step: target_id = docs_target_to_step[ctx_path] if target_id != start_id: # не ссылается на себя cycle = has_cycle(target_id, visited, list(path)) if cycle: return cycle return None # Запускаем cycle detection для каждого шага cycles_found = set() for s in all_steps_for_cycle: visited = set() cycle = has_cycle(s['id'], visited, []) if cycle: # Нормализуем цикл (начинаем с минимального id), чтобы не дублировать min_idx = cycle.index(min(cycle)) normalized = tuple(cycle[min_idx:] + cycle[:min_idx]) if normalized not in cycles_found: cycles_found.add(normalized) issue('R33', f"обнаружен cycle в context_to_read: {' → '.join(cycle)}. " f"Шаги выполняются последовательно — cycle приведёт к deadlock. " f"Разорвите зависимость: уберите context_to_read или измените порядок шагов.") print(f"\n{'='*60}") print(f"Validate Plans — {len(all_steps)} шагов, {len(all_docs_targets)} docs_target") print(f"{'='*60}") # ============================================================ # Аудит v31 (P13): R30 — cross-check общего числа шагов с plans/completion-strategy.md # ============================================================ # completion-strategy.md содержит строку «Итого: NNN шагов» — это число должно # совпадать с суммой step_count всех YAML-планов (без 99-template). completion_strategy = os.path.join(PLANS_DIR, 'completion-strategy.md') if os.path.exists(completion_strategy): with open(completion_strategy, encoding='utf-8') as f: cs_content = f.read() # Найти «Итого: NNN шаг» или «Все NNN шагов выполнены» m = re.search(r'(?:Итого:|Все)\s+(\d+)\s+шаг', cs_content) if m: cs_total = int(m.group(1)) actual_total = len(all_steps) if cs_total != actual_total: issue('R30', f"plans/completion-strategy.md: указано {cs_total} шагов, " f"но реально в YAML-планах {actual_total}. " f"Обновите completion-strategy.md §3 таблицу и §10 критерии.") # Также проверить упоминания «Foundation | 16» — должно быть 12 (аудит v31) if re.search(r'Foundation\s*\|\s*16\b', cs_content): issue('R30', "plans/completion-strategy.md: устаревшая запись 'Foundation | 16' " "(должно быть 12 после аудита v31). Обновите §3 таблицу.") else: # Не ошибка, но предупреждение — не нашли паттерн warnings.append(('R30', "plans/completion-strategy.md: не найдено 'Итого: NNN шагов' — " "невозможно проверить общее число шагов")) # Также проверить ROADMAP.md и 00-manifest.md на устаревшие счётчики Foundation for check_file in ['ROADMAP.md', '00-manifest.md', 'INSTRUCTION.md', 'START-HERE.md', 'docs/index.md']: fpath = os.path.join(BASE, check_file) if os.path.exists(fpath): with open(fpath, encoding='utf-8') as f: content = f.read() # Устаревшие паттерны (только в контексте Foundation или общего счёта) if re.search(r'Foundation\s*\(?\s*16\s*шагов?\)?', content): warnings.append(('R30', f"{check_file}: найдено 'Foundation ... 16 шагов' — " "устарело после аудита v31 (должно быть 12)")) if re.search(r'\b140\s+шагов\b', content): warnings.append(('R30', f"{check_file}: найдено '140 шагов' — " "устарело после аудита v31 (должно быть 142)")) if re.search(r'\b16\s+шагов\s+в\s+6\s+группах\b', content): warnings.append(('R30', f"{check_file}: найдено '16 шагов в 6 группах' — " "устарело (актуально: 6 шагов в 3 группах)")) 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)