/
kochenov
/
uni
Обзор
Документация
Войти
/
kochenov
/
uni
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
main
scripts/validate_rules.py
659 строк
27 KB
Dmitry Kochenov
Initial commit: UMP base structure
16 июл 2026, 21:14
16 июл 2026, 21:14
86b4d0e
Код
Авторство
О чём код?
#!/usr/bin/env python3 """ validate_rules.py — контракт-тест для числовых констант в правилах и промптах. Превентивная защита от расхождения источников истины: числовые упоминания ("6 гейтов", "7 фаз", "9 секций" и т.д.) в .agent/rules/*.md и .agent/prompts/*.md сравниваются с единственным источником — _meta/rules-constants.yaml. Запуск: uv run python3 scripts/validate_rules.py uv run python3 scripts/validate_rules.py --fix Exit codes: 0 — OK (расхождений нет; WARN допустимы) 1 — найдены расхождения (ERR-VR-001, ERR-VR-002) 2 — ошибка (rules-constants.yaml не найден, некорректен, или fatal crash) Проверяемые константы (см. _meta/rules-constants.yaml): gates_count → 8 phases_count → 7 phase1_sections_count → 9 phase2_markers_count → 8 max_debug_iterations_default → 3 cross_session_threshold → 5 (loop_detector.cross_session_threshold) """ from __future__ import annotations import argparse import re import sys from dataclasses import dataclass from pathlib import Path try: import yaml # PyYAML except ImportError: # pragma: no cover yaml = None # обработается в load_yaml_constants # --- Пути -------------------------------------------------------------------- REPO_ROOT = Path(__file__).resolve().parent.parent CONSTANTS_YAML_DEFAULT = REPO_ROOT / "_meta" / "rules-constants.yaml" RULES_DIR = REPO_ROOT / ".agent" / "rules" PROMPTS_DIR = REPO_ROOT / ".agent" / "prompts" # --- Коды проблем ------------------------------------------------------------ ERR_NUMERIC = "ERR-VR-001" # числовое расхождение с yaml ERR_FORBIDDEN_PHRASE = "ERR-VR-002" # запрещённая фраза "Все 6 промптов" WARN_NO_CURRENT_PHASE = "WARN-VR-003" # current_phase_writers без CURRENT_PHASE/sed WARN_LIST_NAMES_MISSING = "WARN-VR-004" # строки из yaml-списков отсутствуют в ожидаемых файлах # --- Метаданные констант ----------------------------------------------------- @dataclass(frozen=True) class ConstantSpec: """Описание одной проверяемой константы.""" yaml_key: str # точечный путь в yaml (напр. "loop_detector.cross_session_threshold") display_key: str # как показывать в выводе (напр. "cross_session_threshold") pattern: re.Pattern # regex с группой (\d+) — индекс capture_group capture_group: int = 1 # индекс группы с числом (по умолчанию 1) description: str = "" # что это за константа (для человека) CONSTANTS: list[ConstantSpec] = [ ConstantSpec( yaml_key="gates_count", display_key="gates_count", pattern=re.compile( r"\b(\d+)\s+(типов?\s+гейтов?|гейтов?)\b", re.UNICODE, ), description="количество типов интерактивных гейтов", ), ConstantSpec( yaml_key="phases_count", display_key="phases_count", pattern=re.compile(r"\b(\d+)\s+фаз\b", re.UNICODE), description="количество фаз жизненного цикла шага", ), ConstantSpec( yaml_key="phase1_sections_count", display_key="phase1_sections_count", pattern=re.compile( r"\b(\d+)\s+(секций|обязательных\s+секций)\s+Phase\s+1\b", re.UNICODE, ), description="количество обязательных секций Phase 1", ), ConstantSpec( yaml_key="phase2_markers_count", display_key="phase2_markers_count", pattern=re.compile( r"\b(\d+)\s+(маркеров|маркера)\s+Phase\s+2\b", re.UNICODE, ), description="количество маркеров Phase 2", ), ConstantSpec( yaml_key="max_debug_iterations_default", display_key="max_debug_iterations_default", pattern=re.compile( r"\b(\d+)\s+итераци[йя]\s+debug\b", re.UNICODE, ), description="максимум итераций debug-fixer по умолчанию", ), # cross_session_threshold — вложен в loop_detector. В markdown-правилах # упоминания встречаются редко (константа используется в основном в коде), # но ключ валидируется на существование в YAML для целостности контракта. ConstantSpec( yaml_key="loop_detector.cross_session_threshold", display_key="cross_session_threshold", pattern=re.compile( r"\b(\d+)\s+(?:cross[-_\s]?session|кросс[-_\s]?сесс)", re.IGNORECASE | re.UNICODE, ), description="порог cross_session для loop detector", ), ] # --- Запрещённые фразы ------------------------------------------------------- # "Все 6 промптов" / "все 6 промптов" запрещено — должно быть "5-6 промптов", # т.к. фактическое количество промптов в жизненном цикле переменное. FORBIDDEN_PROMPTS_PHRASE = re.compile( r"\b(В|в)се\s+6\s+промптов\b", re.UNICODE, ) # --- Модель проблемы -------------------------------------------------------- @dataclass class Issue: code: str severity: str # "КРИТИЧНО" или "ПРЕДУПРЕЖДЕНИЕ" title: str file_rel: str | None = None line: int | None = None found: str | None = None expected_value: str | None = None expected_source: str | None = None # напр. "rules-constants.yaml → gates_count" detail: str | None = None fix_command: str | None = None def is_error(self) -> bool: return self.severity == "КРИТИЧНО" # --- Утилиты ----------------------------------------------------------------- def sed_escape(text: str) -> str: """Экранирует специальные символы sed для s|...|...|.""" return ( text.replace("\\", "\\\\") .replace("|", "\\|") .replace("&", "\\&") ) def make_fix_command(matched_text: str, replacement: str, file_rel: str) -> str: """Формирует sed-команду для замены matched_text на replacement.""" return ( f"sed -i 's|{sed_escape(matched_text)}|{sed_escape(replacement)}|g' " f"{file_rel}" ) def lookup_nested(data: dict, dotted_key: str): """Возвращает значение по точечному пути 'a.b.c' из вложенного dict.""" cur = data for part in dotted_key.split("."): if not isinstance(cur, dict) or part not in cur: raise KeyError( f"ключ '{dotted_key}' (часть '{part}') не найден в yaml" ) cur = cur[part] return cur def load_yaml_constants(path: Path) -> dict: """Загружает rules-constants.yaml. Поднимает FileNotFoundError если файла нет.""" if not path.exists(): raise FileNotFoundError( f"rules-constants.yaml не найден: {path}" ) if yaml is None: raise RuntimeError( "PyYAML не установлен. Установите зависимость: " "`uv add pyyaml` или `pip install pyyaml`." ) with path.open("r", encoding="utf-8") as fh: data = yaml.safe_load(fh) if not isinstance(data, dict): raise RuntimeError( f"Ожидается dict в {path}, получено {type(data).__name__}" ) return data def collect_md_files() -> list[Path]: """Собирает все .md из .agent/rules/ и .agent/prompts/ (сортировка стабильная).""" files: list[Path] = [] for d in (RULES_DIR, PROMPTS_DIR): if d.exists(): files.extend(sorted(d.glob("*.md"))) return files # --- Проверки ---------------------------------------------------------------- def find_numeric_discrepancies( md_files: list[Path], constants_values: dict[str, int], ) -> list[Issue]: """Ищет числовые расхождения по regex-паттернам из CONSTANTS.""" issues: list[Issue] = [] for spec in CONSTANTS: expected = constants_values.get(spec.display_key) if expected is None: # Константа не загружена — пропускаем (ошибка загрузки уже зафиксирована). continue for md in md_files: try: text = md.read_text(encoding="utf-8") except OSError as exc: issues.append(Issue( code=ERR_NUMERIC, severity="КРИТИЧНО", title=f"Не удалось прочитать файл: {exc}", file_rel=str(md.relative_to(REPO_ROOT)), )) continue for lineno, line in enumerate(text.splitlines(), start=1): for m in spec.pattern.finditer(line): found_num = int(m.group(spec.capture_group)) if found_num == expected: continue matched_text = m.group(0) file_rel = str(md.relative_to(REPO_ROOT)) replacement = matched_text.replace( m.group(spec.capture_group), str(expected), 1, ) issues.append(Issue( code=ERR_NUMERIC, severity="КРИТИЧНО", title="Числовое расхождение", file_rel=file_rel, line=lineno, found=matched_text, expected_value=str(expected), expected_source=( f"rules-constants.yaml → {spec.display_key}" ), fix_command=make_fix_command( matched_text, replacement, file_rel ), )) return issues def find_forbidden_prompts_phrase(md_files: list[Path]) -> list[Issue]: """Ищет запрещённую фразу 'Все 6 промптов' / 'все 6 промптов'.""" issues: list[Issue] = [] replacement = "5-6 промптов" for md in md_files: try: text = md.read_text(encoding="utf-8") except OSError: continue for lineno, line in enumerate(text.splitlines(), start=1): for m in FORBIDDEN_PROMPTS_PHRASE.finditer(line): matched = m.group(0) file_rel = str(md.relative_to(REPO_ROOT)) issues.append(Issue( code=ERR_FORBIDDEN_PHRASE, severity="КРИТИЧНО", title="Запрещённая фраза о количестве промптов", file_rel=file_rel, line=lineno, found=matched, expected_value=f'"{replacement}"', expected_source=None, detail=( "Количество промптов в жизненном цикле переменное; " "используйте '5-6 промптов' вместо точного числа." ), fix_command=make_fix_command(matched, replacement, file_rel), )) return issues def check_current_phase_writers( md_files: list[Path], writers: list[str], ) -> list[Issue]: """Каждый промпт из current_phase_writers должен содержать инструкцию обновлять CURRENT_PHASE (через sed -i или вызов finalize_step.py). Иначе — WARN-VR-003. """ issues: list[Issue] = [] available = {p.name: p for p in md_files} # \bsed\b — чтобы не ловить "based", "used" и пр. sed_re = re.compile(r"\bsed\b", re.UNICODE) for writer_name in writers: path = available.get(writer_name) if path is None: issues.append(Issue( code=WARN_NO_CURRENT_PHASE, severity="ПРЕДУПРЕЖДЕНИЕ", title=( f"Промпт из current_phase_writers не найден: {writer_name}" ), detail=( "Файл отсутствует в .agent/prompts/, но прописан в " "rules-constants.yaml → current_phase_writers. " "Либо создайте файл, либо уберите запись из yaml." ), )) continue try: text = path.read_text(encoding="utf-8") except OSError as exc: issues.append(Issue( code=WARN_NO_CURRENT_PHASE, severity="ПРЕДУПРЕЖДЕНИЕ", title=f"Не удалось прочитать {writer_name}: {exc}", file_rel=str(path.relative_to(REPO_ROOT)), )) continue has_current_phase = "CURRENT_PHASE" in text has_updater = bool(sed_re.search(text)) or ( "finalize_step.py" in text ) if has_current_phase and has_updater: continue missing: list[str] = [] if not has_current_phase: missing.append("CURRENT_PHASE") if not has_updater: missing.append("sed -i / finalize_step.py") issues.append(Issue( code=WARN_NO_CURRENT_PHASE, severity="ПРЕДУПРЕЖДЕНИЕ", title=( "Промпт из current_phase_writers не содержит инструкцию " "обновлять CURRENT_PHASE" ), file_rel=str(path.relative_to(REPO_ROOT)), detail=( "Отсутствует: " + ", ".join(missing) + ". " "Ожидается: инструкция с `sed -i` или вызовом `finalize_step.py` " "для атомарного обновления CURRENT_PHASE в STEP_STATE.md " "(см. 00-agent-protocol.md §7.5 — исключение из правила " "одного писателя)." ), )) return issues def check_list_names_in_files(constants: dict, md_files: list[Path]) -> list[Issue]: """FIX-V24-2.4: проверить, что конкретные строки из yaml-списков встречаются в указанных файлах. Проверяемые соответствия: - phase2_markers → 02-documentation-rules.md, verify_step_completion.py, validate_md.py - phases_list → 07-plan-first-mode.md - gates_list → 06-interactive-protocol.md Если строка не найдена ни в одном из target-файлов — WARN-VR-004. """ issues: list[Issue] = [] # --- phase2_markers --- phase2_markers = constants.get('phase2_markers', []) or [] if phase2_markers: target_files = [ REPO_ROOT / '.agent' / 'rules' / '02-documentation-rules.md', REPO_ROOT / 'scripts' / 'verify_step_completion.py', REPO_ROOT / 'scripts' / 'validate_md.py', ] for marker in phase2_markers: found_in_any = False for tf in target_files: if tf.exists(): try: content = tf.read_text(encoding='utf-8', errors='ignore') except OSError: continue if marker in content: found_in_any = True break if not found_in_any: first_rel = ( str(target_files[0].relative_to(REPO_ROOT)) if target_files[0].exists() else str(target_files[0]) ) issues.append(Issue( code=WARN_LIST_NAMES_MISSING, severity="ПРЕДУПРЕЖДЕНИЕ", title="phase2_marker отсутствует в целевых файлах", file_rel=first_rel, line=0, found=f'phase2_marker "{marker}"', expected_value=( 'должен встречаться хотя бы в одном из: ' '02-documentation-rules.md, verify_step_completion.py, ' 'validate_md.py' ), expected_source='rules-constants.yaml → phase2_markers', )) # --- phases_list --- phases_list = constants.get('phases_list', []) or [] if phases_list: target = REPO_ROOT / '.agent' / 'rules' / '07-plan-first-mode.md' if target.exists(): try: content = target.read_text(encoding='utf-8') except OSError: content = '' for phase in phases_list: if phase not in content: issues.append(Issue( code=WARN_LIST_NAMES_MISSING, severity="ПРЕДУПРЕЖДЕНИЕ", title="phase отсутствует в 07-plan-first-mode.md", file_rel=str(target.relative_to(REPO_ROOT)), line=0, found=f'phase "{phase}"', expected_value='должна упоминаться в 07-plan-first-mode.md', expected_source='rules-constants.yaml → phases_list', )) # --- gates_list --- gates_list = constants.get('gates_list', []) or [] if gates_list: target = REPO_ROOT / '.agent' / 'rules' / '06-interactive-protocol.md' if target.exists(): try: content = target.read_text(encoding='utf-8') except OSError: content = '' for gate in gates_list: if gate not in content: issues.append(Issue( code=WARN_LIST_NAMES_MISSING, severity="ПРЕДУПРЕЖДЕНИЕ", title="gate отсутствует в 06-interactive-protocol.md", file_rel=str(target.relative_to(REPO_ROOT)), line=0, found=f'gate "{gate}"', expected_value='должен упоминаться в 06-interactive-protocol.md', expected_source='rules-constants.yaml → gates_list', )) return issues # --- Форматирование ---------------------------------------------------------- def format_issue(issue: Issue) -> str: """Форматирует Issue в человекочитаемый блок (по шаблону из ТЗ).""" lines: list[str] = [] lines.append(f"{issue.code} [{issue.severity}] {issue.title}") if issue.file_rel: lines.append(f"Файл: {issue.file_rel}") if issue.line is not None: lines.append(f"Строка: {issue.line}") if issue.found is not None: lines.append(f"Найдено: \"{issue.found}\"") if issue.expected_value is not None: if issue.expected_source: lines.append( f"Ожидается ({issue.expected_source}): {issue.expected_value}" ) else: lines.append(f"Ожидается: {issue.expected_value}") if issue.detail: lines.append(issue.detail) return "\n".join(lines) def emit_fix_commands(issues: list[Issue]) -> None: """Выводит sed-команды для исправления найденных расхождений (--fix).""" print("\n# --- Предлагаемые команды для исправления (--fix) ---") printed = 0 for issue in issues: if issue.fix_command: print(issue.fix_command) printed += 1 if printed == 0: print("# Нет автоматических исправлений для найденных проблем.") else: print(f"# Всего команд: {printed}") print( "# Примечание: команды НЕ применяются автоматически. " "Проверьте и запустите вручную." ) # --- Main -------------------------------------------------------------------- def main() -> int: parser = argparse.ArgumentParser( prog="validate_rules.py", description=( "Контракт-тест: числовые константы в .agent/rules/*.md и " ".agent/prompts/*.md vs _meta/rules-constants.yaml." ), ) parser.add_argument( "--fix", action="store_true", help="Вывести sed-команды для исправления (без применения).", ) parser.add_argument( "--constants", type=Path, default=CONSTANTS_YAML_DEFAULT, help=( f"Путь к rules-constants.yaml " f"(по умолчанию: {CONSTANTS_YAML_DEFAULT})." ), ) args = parser.parse_args() try: # 1. Загрузка констант ------------------------------------------------ try: yaml_data = load_yaml_constants(args.constants) except FileNotFoundError as exc: print(f"FATAL: {exc}", file=sys.stderr) return 2 # Резолвим все константы из CONSTANTS в плоский dict {display_key: value} constants_values: dict[str, int] = {} load_errors: list[str] = [] for spec in CONSTANTS: try: value = lookup_nested(yaml_data, spec.yaml_key) except KeyError as exc: load_errors.append( f"Константа '{spec.display_key}' (yaml: {spec.yaml_key}): " f"{exc}" ) continue if not isinstance(value, int): load_errors.append( f"Константа '{spec.display_key}' (yaml: {spec.yaml_key}) " f"имеет тип {type(value).__name__}, ожидается int " f"(значение: {value!r})" ) continue constants_values[spec.display_key] = value if load_errors: print( "FATAL: rules-constants.yaml некорректен:", file=sys.stderr, ) for msg in load_errors: print(f" - {msg}", file=sys.stderr) return 2 # 2. Сбор .md файлов -------------------------------------------------- md_files = collect_md_files() if not md_files: print( "WARN: не найдено .md файлов в .agent/rules/ или " ".agent/prompts/", file=sys.stderr, ) # 3. Числовые расхождения -------------------------------------------- issues: list[Issue] = [] issues.extend(find_numeric_discrepancies(md_files, constants_values)) # 4. Запрещённая фраза "Все 6 промптов" ------------------------------ issues.extend(find_forbidden_prompts_phrase(md_files)) # 5. current_phase_writers → CURRENT_PHASE + (sed | finalize_step.py) writers = yaml_data.get("current_phase_writers", []) or [] if not isinstance(writers, list): print( "WARN: current_phase_writers не является списком — " "пропуск проверки", file=sys.stderr, ) writers = [] issues.extend(check_current_phase_writers(md_files, writers)) # 5b. FIX-V24-2.4: строки из yaml-списков (phase2_markers, phases_list, # gates_list) должны встречаться в ожидаемых файлах. issues.extend(check_list_names_in_files(yaml_data, md_files)) # 6. Сортировка: ERR прежде WARN; затем по файлу и строке ---------- def sort_key(issue: Issue): severity_rank = 0 if issue.is_error() else 1 return ( severity_rank, issue.file_rel or "", issue.line or 0, issue.code, ) issues.sort(key=sort_key) # 7. Вывод ----------------------------------------------------------- errs = [i for i in issues if i.is_error()] warns = [i for i in issues if not i.is_error()] if not issues: print("OK: расхождений не найдено.") print(f" Проверено файлов: {len(md_files)}") print(f" Проверено констант: {len(constants_values)}") if writers: print(f" Проверено current_phase_writers: {len(writers)}") return 0 for issue in issues: print(format_issue(issue)) print() # пустая строка между блоками print("--- ИТОГ ---") print(f"КРИТИЧНО: {len(errs)}") print(f"ПРЕДУПРЕЖДЕНИЕ: {len(warns)}") print(f"Проверено файлов: {len(md_files)}") print(f"Проверено констант: {len(constants_values)}") if args.fix: emit_fix_commands(issues) return 1 if errs else 0 except Exception as exc: # Crash safety: любая непредвиденная ошибка → exit 2 с диагностикой. print( f"FATAL: непредвиденная ошибка: " f"{type(exc).__name__}: {exc}", file=sys.stderr, ) return 2 if __name__ == "__main__": sys.exit(main())