/
ump-team
/
ump-infra
Обзор
Документация
Войти
/
ump-team
/
ump-infra
Код
Запросы
1
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
dev/test
scripts/validate_plans.py
541 строка
29 KB
Dmitry Kochenov
fix: исправить 46 ошибок basedpyright и добавить type hint stubs
09 авг 2026, 15:25
09 авг 2026, 15:25
8a46eb7
Код
Авторство
О чём код?
#!/usr/bin/env python3 """ Dry-run валидатор YAML-планов (I6). Запуск: uv run python scripts/validate_plans.py [plans_dir] Exit 0 = VALID, Exit 1 = INVALID. P0-12 fix (v0.0.2): добавлен опциональный positional аргумент plans_dir. Если не указан — берётся из ump-ui-config.yaml или <base>/plans/. Правила: 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 — () parallel_group — шаги в одной группе не зависят друг от друга через context_to_read (no intra-group cycles) R30 — cross-check: общее число шагов в plans/completion-strategy.md совпадает с суммой step_count всех YAML-планов; проверка устаревших счётчиков «Foundation 16 шагов» / «140 шагов» в ROADMAP, 00-manifest и др. R31 — (P0-13 v0.0.2) ai_ready.schema_version присутствует и равно '2.0' R32 — (P0-13 v0.0.2) ai_ready.file_purpose присутствует R33 — (P0-13 v0.0.2) ai_ready.docs_source_of_truth присутствует и true R34 — (P0-13 v0.0.2) ai_ready.dependencies — список (≥1 элемент) R35 — (P0-13 v0.0.2) ai_ready.step_count совпадает с реальным числом шагов R36 — (P0-13 v0.0.2) step.branch присутствует (или step_type=user_action) R37 — (P0-13 v0.0.2) step.context_to_read присутствует (или step_type=bootstrap/user_action) """ import os import re import sys import yaml # В v0.0.1: путь к plans берётся из ump-ui-config.yaml, если он есть. # Fallback на <base>/plans для legacy проектов. BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # P0-2 fix (из v0.0.1): Если ump.py в .ump/scripts/ — BASE это .ump/, нужно подняться на уровень выше if os.path.basename(BASE) == '.ump': BASE = os.path.dirname(BASE) # P0-12 fix: поддержка positional аргумента plans_dir (через argparse в main()). # Глобальная переменная PLANS_DIR устанавливается из argparse или _resolve_plans_dir(). def _resolve_plans_dir(override=None): """Определить путь к plans/ из argparse / ump-ui-config.yaml / fallback.""" if override: # P0-12 fix: honour positional argument if os.path.isabs(override): return override return os.path.join(BASE, override) config_path = os.path.join(BASE, 'ump-ui-config.yaml') if os.path.exists(config_path): try: with open(config_path, encoding='utf-8') as f: cfg = yaml.safe_load(f) or {} plans_rel = (cfg.get('paths') or {}).get('plans', 'plans/') return os.path.join(BASE, plans_rel) except Exception: pass return os.path.join(BASE, 'plans') PLANS_DIR = _resolve_plans_dir() # default; может быть переопределён в main() # P0-12 fix: обработать positional аргумент plans_dir из sys.argv # (минимальная инвазия — без полного рефакторинга в main()) if __name__ == '__main__' and len(sys.argv) > 1 and not sys.argv[1].startswith('-'): _arg_plans_dir = sys.argv[1] # Уберём аргумент из sys.argv, чтобы не путать argparse (если будет добавлен) PLANS_DIR = _resolve_plans_dir(_arg_plans_dir) sys.argv.pop(1) 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: list = [] warnings: list = [] all_steps: dict[str, str] = {} all_docs_targets: dict[str, str] = {} # dt → fname (для R07) all_docs_targets_set: set[str] = set() # FIX-4.9: множество всех docs_target (для R10) all_adr_numbers: dict[str, list] = {} # ADR-номер → [(step_id, file)] # P0-13 fix: обязательные поля ai_ready (R31-R35) AI_READY_REQUIRED = { 'schema_version': ('R31', "должно быть '2.0'"), 'file_purpose': ('R32', "краткое описание этапа"), 'docs_source_of_truth': ('R33', "должно быть true"), 'dependencies': ('R34', "список файлов-зависимостей (≥1 элемент)"), 'step_count': ('R35', "число шагов (должно совпадать с реальным)"), } # P0-13 fix: обязательные поля step (R36-R37) — для не-bootstrap и не-user_action STEP_REQUIRED_BRANCH_TYPES = {'implementation', 'document', 'adr', 'finalization'} STEP_REQUIRED_CONTEXT_TYPES = {'implementation', 'document', 'adr', 'finalization'} 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 # user_action шаги: пропускаем строгие проверки R02/R03 (нет промптов). # В v0.0.1 этап онбординга удалён, но user_action шаги могут быть в пользовательских планах. 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)}') # P0-13 fix: проверки обязательных полей ai_ready (R31-R35) ai_ready = data.get('ai_ready', {}) or {} for field, (rule, hint) in AI_READY_REQUIRED.items(): if field not in ai_ready: issue(rule, f'[{fname}]: ai_ready.{field} отсутствует — {hint}') elif field == 'schema_version' and ai_ready[field] != '2.0': issue('R31', f'[{fname}]: ai_ready.schema_version={ai_ready[field]!r}, ожидалось \'2.0\'') elif field == 'docs_source_of_truth' and ai_ready[field] is not True: issue('R33', f'[{fname}]: ai_ready.docs_source_of_truth={ai_ready[field]!r}, должно быть true') elif field == 'dependencies': deps = ai_ready[field] if not isinstance(deps, list) or len(deps) < 1: issue('R34', f'[{fname}]: ai_ready.dependencies должен быть список ≥1 элемента, got: {deps!r}') elif field == 'step_count' and ai_ready[field] != len(steps): issue('R35', f'[{fname}]: ai_ready.step_count={ai_ready[field]} ≠ реально {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', '') # P0-13 fix: R36 — step.branch обязателен для implementation/document/adr/finalization if step_type in STEP_REQUIRED_BRANCH_TYPES and 'branch' not in step: issue('R36', f'[{fname}] {sid}: step.branch отсутствует (обязателен для step_type={step_type})') # P0-13 fix: R37 — step.context_to_read обязателен для implementation/document/adr/finalization if step_type in STEP_REQUIRED_CONTEXT_TYPES and 'context_to_read' not in step: issue('R37', f'[{fname}] {sid}: step.context_to_read отсутствует (обязателен для step_type={step_type})') # R01 title length if len(title) > 60: issue('R01', f'[{fname}] {sid}: title {len(title)} символов: {title}') # R02 pre-flight first — пропускаем для user_action (нет промптов) if step_type != 'user_action' and (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 step_type != 'user_action' and 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: 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 # v0.0.1: ищем файлы в BASE/, BASE/.ump/ (submodule layout) def _file_exists_in_project(rel_path: str) -> bool: """Проверить существование файла в BASE или BASE/.ump/.""" return any( os.path.exists(os.path.join(base, rel_path)) for base in [BASE, os.path.join(BASE, '.ump')] ) for ref in ctx: ref_path = ref.split('#')[0] if ref_path.startswith('docs/'): if ref_path in all_docs_targets_set: continue # OK — это docs_target какого-то шага if 'docs/руководство/' in ref_path: continue if 'docs/.vitepress/' in ref_path: continue if 'docs/для-разработчиков/adr/' in ref_path: continue if _file_exists_in_project(ref_path): continue issue('R10', f'[{fname}] {sid}: docs/ путь не является docs_target ни одного шага и не существует: {ref_path}') continue # STEP_STATE.md — динамический, создаётся на шаге 1.0 if ref_path == 'STEP_STATE.md': continue if not _file_exists_in_project(ref_path): 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': 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 # v0.0.1: ищем в BASE/, BASE/.ump/ (submodule layout) if step_type != 'user_action': for p in prompts: p_path = p.split('#')[0] candidates = [ os.path.join(BASE, p_path), os.path.join(BASE, '.ump', p_path), os.path.join(BASE, '.ump', p_path.lstrip('/')), ] if not any(os.path.exists(c) for c in candidates): 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') and 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) and 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 () 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: dict[str, list] = {} # group_name → list of (step_id, docs_target, context_to_read) all_steps_for_cycle: list = [] # для 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: DEPRECATED warning для parallel_group pg = step.get('parallel_group') if pg: warn('R32', f"[{fname}] {step.get('id')}: поле `parallel_group: {pg}` DEPRECATED. " 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 — 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 = set() for s in all_steps_for_cycle: visited: set[str] = 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}") # ============================================================ # ============================================================ # 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 критерии.") # P1-14 fix (v0.0.2): обновлены устаревшие хардкоды (Foundation 12 → 14, 140 → 138) # Примечание: в v0.0.1 Foundation был 14 шагов, всего 138 else: # Не ошибка, но предупреждение — не нашли паттерн warnings.append(('R30', "plans/completion-strategy.md: не найдено 'Итого: NNN шагов' — " "невозможно проверить общее число шагов")) # Также проверить ROADMAP.md и 00-manifest.md на устаревшие счётчики Foundation # P1-14 fix (v0.0.2): актуальные числа — Foundation 14 шагов, всего 138 for check_file in ['ROADMAP.md', '00-manifest.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 шагов' — " "устарело (актуально: 14 шагов после аудита v31/v0.0.2)")) if re.search(r'Foundation\s*\(?\s*12\s*шагов?\)?', content): warnings.append(('R30', f"{check_file}: найдено 'Foundation ... 12 шагов' — " "устарело (актуально: 14 шагов после аудита v0.0.2)")) if re.search(r'\b140\s+шагов\b', content): warnings.append(('R30', f"{check_file}: найдено '140 шагов' — " "устарело (актуально: 138 шагов)")) if re.search(r'\b142\s+шагов\b', content): warnings.append(('R30', f"{check_file}: найдено '142 шагов' — " "устарело (актуально: 138 шагов)")) 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)