/
kochenov
/
universal-modular
Обзор
Документация
Войти
/
kochenov
/
universal-modular
Код
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
scripts/validate_md.py
551 строка
29 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 """ Валидатор MD-файлов для Universal Modular Platform. Запуск: uv run python scripts/validate_md.py <path-to-md-file> uv run python scripts/validate_md.py docs/01-foundation/step-1-5-pyproject.md uv run python scripts/validate_md.py --all # проверить все .md в docs/ Exit 0 = VALID, Exit 1 = INVALID. Проверяет (правила M01–M14): M01 — ровно один H1 заголовок (#) в начале файла M02 — все тройные бэктики ``` сбалансированы (чётное количество) M03 — все таблицы имеют разделитель |---| после строки заголовка M04 — во всех строках таблицы одинаковое количество колонок | M05 — нет незакрытых угловых плейсхолдеров <...> вне код-блоков M06 — нет английских разговорных фраз в русской прозе (вне код-блоков и таблиц) M07 — перед и после каждой строки заголовка # есть пустая строка M08 — для Phase 1 docs_target: присутствует маркер "> Phase 1 — инструкция для AI-агента" M09 — для Phase 2 docs_target: присутствует маркер "> Обучающая документация" M10 — для Phase 2: присутствуют все обязательные секции M11 — для Phase 2: отсутствует маркер Phase 1 M12 — для Phase 2: VERIFIED_CHECKLIST имеет имя "## VERIFIED_CHECKLIST" (не "## Критерии готовности (...)") M13 — для Phase 2: все пункты VERIFIED_CHECKLIST закрыты [x] (не [ ]) M14 — нет TODO/FIXME/XXX/placeholder вне код-блоков Контекст: Аудит v5 (.agent/prompts/ и .agent/rules/) выявил системные баги форматирования MD-файлов, которые приводят к: - сломанным таблицам (несогласованные колонки) - незакрытым код-блокам (весь последующий контент становится кодом) - оставшимся плейсхолдерам <...> в финальной документации - гибридным словам (официальная → 官方ная) - потере сохраняемых секций при переходе Phase 1 → Phase 2 Этот скрипт — автоматизированная проверка, дополняющая step-finalizer.md §1.9. """ import re import sys from pathlib import Path # --- Конфигурация --- # Английские разговорные фразы, запрещённые в русской прозе # (коды статуса OK/FAIL/PASS/PASSED/SKIP разрешены — см. 00-agent-protocol.md §2.0 п.7) FORBIDDEN_ENGLISH_PHRASES = [ r'\bLet me\b', r"\bI'll\b", r'\bI will\b', r'\bDone\b', r'\bSuccess\b', r'\bFailed\b', r'\bRun command\?\b', r'\bApprove\?\b', r'\bShould I continue\?\b', r'\bSure\b', r'\bOf course\b', r"\bHere's\b", r"\bLet's\b", ] # Запрещённые плейсхолдеры (вне код-блоков) FORBIDDEN_PLACEHOLDERS = [r'TODO', r'FIXME', r'XXX', r'placeholder'] # Гибридные/несуществующие слова (официальная → 官方ная и подобные) FORBIDDEN_HYBRID_WORDS = [ '官方ная', '官方ной', '官方ную', # гибрид китайского и русского ] # Обязательные секции Phase 2 (step-finalizer.md §1.2) PHASE2_REQUIRED_SECTIONS = [ '## Что вы получите в конце шага', '## Предварительные требования', '## Почему именно так', '## Пошаговая инструкция', '## Как проверить', '## Типичные ошибки и их решения', '## Что дальше', ] # Сохраняемые секции (Phase 1 → Phase 2, не должны меняться) PRESERVED_SECTIONS = [ '## История действий агента', '## Реестр файлов шага', '## Реестр тестов шага', '## VERIFIED_CHECKLIST', ] # Старое (неправильное) имя секции VERIFIED_CHECKLIST OLD_VERIFIED_NAME = '## Критерии готовности (VERIFIED_CHECKLIST)' class Issue: def __init__(self, rule, line, col, msg, severity='ERROR'): self.rule = rule self.line = line self.col = col self.msg = msg self.severity = severity # ERROR or WARN def __str__(self): loc = f'{self.line}' if self.col: loc += f':{self.col}' icon = '❌' if self.severity == 'ERROR' else '⚠️' return f' {icon} {self.rule:5} [{loc}] {self.msg}' def split_code_blocks(lines): """Разделяет строки на (in_code_block, line) пары. Возвращает список кортежей (is_in_code_block: bool, line: str). Учитывает только тройные бэктики ``` (не одиночные `). """ result = [] in_code = False for line in lines: # Подсчёт тройных бэктиков в строке (вне экранированных) # Простая эвристика: считаем ``` не внутри одинарных/двойных кавычек stripped = line # Удаляем inline-код `...` чтобы не путать с ``` # (грубая аппроксимация — для большинства случаев достаточно) count = stripped.count('```') if count % 2 == 1: result.append((in_code, line)) in_code = not in_code else: result.append((in_code, line)) return result def validate_file(filepath, strict_placeholders=True): """Валидирует один MD-файл. Возвращает список Issue. Args: filepath: путь к файлу strict_placeholders: если True — проверять плейсхолдеры <...> (для финальных выводов docs/). Если False — пропустить M05/M14 (для шаблонов .agent/ и корневых файлов, где <placeholder> — часть шаблона). """ issues = [] try: with open(filepath, encoding='utf-8') as f: content = f.read() except Exception as e: issues.append(Issue('M00', 0, 0, f'Не удалось прочитать файл: {e}')) return issues lines = content.split('\n') if not lines: issues.append(Issue('M00', 0, 0, 'Файл пустой')) return issues # --- M01: ровно один H1 в начале файла --- # Подсчитываем H1 только ВНЕ код-блоков # Пропускаем YAML frontmatter (открывается --- в первой строке, закрывается --- позже) # Audit v7: для VitePress home layout (index.md) H1 может быть в конце файла # — это норма для layout: home, не баг. is_vitepress_home = False try: with open(filepath, encoding='utf-8') as f: first_3_lines = [f.readline().strip() for _ in range(3)] if first_3_lines[0] == '---' and any('layout: home' in l for l in first_3_lines): is_vitepress_home = True except Exception: pass frontmatter_end = 0 if lines and lines[0].strip() == '---': for i in range(1, len(lines)): if lines[i].strip() == '---': frontmatter_end = i + 1 break h1_lines = [] for i, (is_in_code, line) in enumerate(split_code_blocks(lines)): if is_in_code: continue if i + 1 <= frontmatter_end: continue # пропускаем frontmatter if line.startswith('# ') and not line.startswith('## '): h1_lines.append((i + 1, line)) if is_vitepress_home: # Для VitePress home layout H1 не обязателен (вся структура через hero/features) pass elif len(h1_lines) == 0: issues.append(Issue('M01', 0, 0, 'Отсутствует H1 заголовок (#) в файле (вне код-блоков и frontmatter)')) elif len(h1_lines) > 1: for ln, line in h1_lines: issues.append(Issue('M01', ln, 0, f'Найден повторный H1: {line[:60]}')) elif h1_lines[0][0] != frontmatter_end + 1: # H1 не сразу после frontmatter — допустимо только если перед ним пустые строки h1_line_num = h1_lines[0][0] non_empty_before = [] for i in range(frontmatter_end, h1_line_num - 1): if lines[i].strip(): non_empty_before.append(i + 1) if non_empty_before: issues.append(Issue('M01', h1_line_num, 0, f'H1 не сразу после frontmatter (перед ним контент на строках {non_empty_before})')) # --- Разделение на код-блоки --- split_lines = split_code_blocks(lines) # --- M02: балансировка тройных бэктиков --- total_backticks = content.count('```') if total_backticks % 2 != 0: # Найти последнюю незакрытую строку last_open = None in_code = False for i, (is_in_code, line) in enumerate(split_lines): if line.count('```') % 2 == 1: if not in_code: last_open = i + 1 in_code = True else: in_code = False loc = last_open if last_open else 0 issues.append(Issue('M02', loc, 0, f'Незакрытый код-блок: всего ``` = {total_backticks} (нечётное). ' f'Весь контент после последнего открытого ``` становится кодом.')) # --- M03, M04: проверка таблиц --- # Собираем блоки строк-таблиц (последовательные строки, начинающиеся с |) table_blocks = [] current_block = [] current_block_start = 0 for i, (is_in_code, line) in enumerate(split_lines): if is_in_code: if current_block: table_blocks.append((current_block_start, current_block)) current_block = [] continue stripped = line.strip() if stripped.startswith('|') and stripped.endswith('|'): if not current_block: current_block_start = i + 1 current_block.append((i + 1, stripped)) else: if current_block: table_blocks.append((current_block_start, current_block)) current_block = [] if current_block: table_blocks.append((current_block_start, current_block)) for start_line, block in table_blocks: if len(block) < 2: # Таблица из одной строки — нет разделителя issues.append(Issue('M03', start_line, 0, f'Таблица из 1 строки (нет разделителя |---|): {block[0][1][:50]}')) continue # M03: вторая строка должна быть разделителем |---| second_line = block[1][1] # Разделитель: |---|, |:---|, |---:|, |:---:|, с пробелами if not re.match(r'^\|[\s\-:|]+\|$', second_line): issues.append(Issue('M03', block[1][0], 0, f'Вторая строка таблицы не разделитель |---|: {second_line[:50]}')) # M04: одинаковое количество колонок # Учитываем экранированные трубы \| (не считаем их как разделители) # Учитываем трубы внутри inline-кода `...` (не считаем их как разделители) inline_code_re_m04 = re.compile(r'`[^`]+`') col_counts = [] for ln, line in block: # Заменяем экранированные \| на placeholder перед подсчётом cleaned = line.replace('\\|', 'PIPE') # Заменяем inline-код `...` на placeholder (трубы внутри не считаем) cleaned = inline_code_re_m04.sub('`CODE`', cleaned) # Считаем количество | минус 1 (т.к. |a|b| = 2 колонки, 3 символа |) if cleaned.startswith('|') and cleaned.endswith('|'): count = cleaned.count('|') - 1 else: count = cleaned.count('|') col_counts.append((ln, count)) if len(set(c for _, c in col_counts)) > 1: # Есть разногласия в количестве колонок expected = col_counts[0][1] for ln, count in col_counts[1:]: if count != expected: issues.append(Issue('M04', ln, 0, f'Колонок: {count}, ожидалось: {expected} ' f'(по заголовку таблицы на строке {start_line})')) # --- M05: угловые плейсхолдеры вне код-блоков --- # Применяется ТОЛЬКО к финальным выводам (docs/). Для шаблонов .agent/ # и корневых файлов <placeholder> — часть шаблона, не баг. if strict_placeholders: placeholder_re = re.compile(r'<([^>]+)>') # Список допустимых placeholder'ов в руководствах (пользователь заменяет на своё значение) # — это НЕ баг, это инструкция. См. audit v7. ALLOWED_PLACEHOLDERS_IN_GUIDE = { 'username', 'owner', 'your_email@example.com', 'you', 'personal-owner', 'work', 'path', 'version', 'X.Y', 'NN', 'N', 'M', } for i, (is_in_code, line) in enumerate(split_lines): if is_in_code: continue # Пропускаем комментарии HTML if line.strip().startswith('<!--'): continue # Вырезаем inline-код `...` перед проверкой line_no_code = inline_code_re_m04.sub('`CODE`', line) if 'inline_code_re_m04' in dir() else line for m in placeholder_re.finditer(line): content_inner = m.group(1).strip() # Разрешаем: HTML-теги if content_inner.lower() in ['br', 'sup', 'sub', 'b', '/b', 'i', '/i', 'strong', '/strong', 'em', '/em', 'code', '/code', 'hr', '/hr']: continue # Разрешаем: допустимые placeholder'ы в руководствах (audit v7) if content_inner.lower() in ALLOWED_PLACEHOLDERS_IN_GUIDE: continue issues.append(Issue('M05', i + 1, m.start() + 1, f'Незаменённый плейсхолдер: <{content_inner}>. ' f'Все <...> должны быть заменены на реальный контент.')) # --- M06: английские фразы в русской прозе --- # Пропускаем строки, где фразы упомянуты как ПРИМЕРЫ запрещённых: # - в кавычках-ёлочках «...» или обычных "..." или '...' # - после слов "типа", "вроде", "запрещен", "запрещён", "forbidden", "пример" # - в инструкциях "НЕ используй", "не использовать" # Также пропускаем inline-код `...` (вырезаем его перед проверкой) inline_code_re = re.compile(r'`[^`]+`') for i, (is_in_code, line) in enumerate(split_lines): if is_in_code: continue # Пропускаем строки таблиц (в них могут быть коды статуса) if line.strip().startswith('|'): continue # Пропускаем строки-цитаты с кодами статуса → OK, → FAIL if '→ OK' in line or '→ FAIL' in line or '→ PASS' in line: continue # Вырезаем inline-код `...` перед проверкой line_without_code = inline_code_re.sub('`CODE`', line) # Пропускаем строки-примеры (содержат кавычки-ёлочки или слово "запрещён/типа/вроде") lower_line = line_without_code.lower() is_example_context = ( '«' in line_without_code or '»' in line_without_code or 'запрещен' in lower_line or 'запрещён' in lower_line or 'типа' in lower_line or 'вроде' in lower_line or 'forbidden' in lower_line or 'пример' in lower_line or 'не используй' in lower_line or 'не использовать' in lower_line or 'english' in lower_line or 'недопустимо' in lower_line or 'корректно' in lower_line ) if is_example_context: continue for pattern in FORBIDDEN_ENGLISH_PHRASES: for m in re.finditer(pattern, line_without_code, re.IGNORECASE): issues.append(Issue('M06', i + 1, m.start() + 1, f'Английская фраза в русской прозе: "{m.group(0)}"')) # --- Гибридные слова (官方ная и подобные) --- for i, line in enumerate(lines): for word in FORBIDDEN_HYBRID_WORDS: if word in line: idx = line.index(word) issues.append(Issue('M06', i + 1, idx + 1, f'Гибридное/несуществующее слово: "{word}". ' f'Замените на правильное русское слово.')) # --- M07: пустые строки вокруг заголовков --- # Пропускаем заголовки внутри код-блоков (например, bash-комментарии # foo) # и YAML-блоки сразу после заголовка (## CURRENT_STATE\nKEY=value) for i, (is_in_code, line) in enumerate(split_lines): if is_in_code: continue if not re.match(r'^#{1,6}\s', line): continue # Проверяем строку перед (кроме первой строки файла и frontmatter) if i > 0 and i + 1 > frontmatter_end: prev = lines[i - 1] if prev.strip() and not prev.strip().startswith('```'): issues.append(Issue('M07', i + 1, 0, f'Перед заголовком "{line[:40]}" нет пустой строки ' f'(строка {i}: "{prev[:40]}")')) # Проверяем строку после if i < len(lines) - 1: nxt = lines[i + 1] if nxt.strip() and not nxt.strip().startswith('```'): # Разрешаем YAML-блоки (KEY=value) сразу после заголовка # (например, ## CURRENT_STATE\nCURRENT_STAGE=01) if re.match(r'^[A-Z_]+=', nxt.strip()): continue # Разрешаем таблицы сразу после заголовка if nxt.strip().startswith('|'): continue # Разрешаем ```text/yaml/bash сразу после заголовка if nxt.strip().startswith('```'): continue # Разрешаем нумерованные и маркированные списки сразу после заголовка # (стандартный markdown-стиль: ## Заголовок\n- пункт) if re.match(r'^(\d+\.|-|\*)\s', nxt.strip()): continue issues.append(Issue('M07', i + 1, 0, f'После заголовка "{line[:40]}" нет пустой строки ' f'(строка {i + 2}: "{nxt[:40]}")')) # --- M14: TODO/FIXME/XXX/placeholder вне код-блоков --- # Применяется ТОЛЬКО к финальным выводам (docs/). if strict_placeholders: for i, (is_in_code, line) in enumerate(split_lines): if is_in_code: continue for pattern in FORBIDDEN_PLACEHOLDERS: for m in re.finditer(pattern, line, re.IGNORECASE): # Разрешаем упоминание этих слов в контексте правил # (например, "НЕ используй TODO" — это инструкция) if 'НЕ используй' in line or 'запрещен' in line.lower() or 'запрещён' in line.lower(): continue issues.append(Issue('M14', i + 1, m.start() + 1, f'Маркер плейсхолдера: "{m.group(0)}"')) # --- Определение фазы файла --- is_phase1 = '> Phase 1 — инструкция для AI-агента' in content is_phase2 = '> Обучающая документация' in content if is_phase1 and not is_phase2: # Phase 1 — только базовые проверки + M08 # M08: маркер Phase 1 присутствует (уже проверено выше) pass elif is_phase2: # --- M09: маркер Phase 2 присутствует (уже проверено) --- # --- M10: обязательные секции Phase 2 --- # Применяется только к финальным выводам (docs/), не к rule-файлам, # которые описывают обе фазы как документацию if strict_placeholders: for section in PHASE2_REQUIRED_SECTIONS: if section not in content: issues.append(Issue('M10', 0, 0, f'Отсутствует обязательная секция Phase 2: "{section}"')) # --- M11: маркер Phase 1 должен быть удалён --- # Применяется только к финальным выводам (docs/). Rule-файлы могут # описывать обе фазы (и Phase 1, и Phase 2) как документацию. if strict_placeholders and is_phase1: for i, line in enumerate(lines): if '> Phase 1 — инструкция для AI-агента' in line: issues.append(Issue('M11', i + 1, 0, 'Маркер Phase 1 не удалён. Phase 2 должна полностью ' 'заменить маркер Phase 1.')) break # --- M12: имя секции VERIFIED_CHECKLIST --- if OLD_VERIFIED_NAME in content: for i, line in enumerate(lines): if OLD_VERIFIED_NAME in line: issues.append(Issue('M12', i + 1, 0, f'Старое имя секции: "{line.strip()}". ' f'Должно быть "## VERIFIED_CHECKLIST" (согласовано с ' f'docs-writer.md и step-finalizer.md).')) break # --- M13: все пункты VERIFIED_CHECKLIST закрыты --- in_checklist = False in_code_when_checklist = False for i, (is_in_code, line) in enumerate(split_lines): if line.strip().startswith('## VERIFIED_CHECKLIST'): in_checklist = True continue if in_checklist: if is_in_code: continue # Если встретили новый ## заголовок — конец VERIFIED_CHECKLIST if line.strip().startswith('## ') and not line.strip().startswith('### '): in_checklist = False continue # Ищем незакрытые пункты [ ] m = re.match(r'^\s*- \[ \]\s+(.+)$', line) if m: issues.append(Issue('M13', i + 1, 0, f'Незакрытый пункт VERIFIED_CHECKLIST: "{m.group(1)[:50]}". ' f'Должен быть [x], не [ ].')) elif not is_phase1 and not is_phase2: # Не Phase 1 и не Phase 2 — возможно, это статический MD (rules, prompts, README) # В этом случае M08–M13 не применяются pass return issues def main(): if len(sys.argv) < 2: print(__doc__) print('\nИспользование:') print(' uv run python scripts/validate_md.py <path-to-md-file>') print(' uv run python scripts/validate_md.py --all') sys.exit(2) if sys.argv[1] == '--all': # Проверить все .md в docs/ и .agent/ base = Path(__file__).parent.parent md_files = [] for pattern in ['docs/**/*.md', '.agent/**/*.md', '*.md']: md_files.extend(base.glob(pattern)) md_files = sorted(set(md_files)) if not md_files: print('MD-файлы не найдены.') sys.exit(1) else: md_files = [Path(sys.argv[1])] total_issues = 0 total_errors = 0 total_warnings = 0 print(f"\n{'=' * 70}") print(f"Validate MD — {len(md_files)} файл(ов)") print(f"{'=' * 70}") for filepath in md_files: if not filepath.exists(): print(f'\n📄 {filepath} — НЕ НАЙДЕН') total_errors += 1 continue # Определяем режим проверки: # - Файлы в docs/ (кроме .vitepress/) — финальные выводы, strict_placeholders=True # - Файлы в .agent/ и корневые — шаблоны/правила, strict_placeholders=False path_str = str(filepath) is_final_output = ( '/docs/' in path_str or (path_str.endswith('.md') and '/docs' in path_str) ) and '.vitepress' not in path_str # Более точная проверка: если путь содержит /docs/ и не содержит /.vitepress/ is_final_output = ('/docs/' in path_str or path_str.startswith('docs/')) and '.vitepress' not in path_str issues = validate_file(filepath, strict_placeholders=is_final_output) errors = [i for i in issues if i.severity == 'ERROR'] warnings = [i for i in issues if i.severity == 'WARN'] total_issues += len(issues) total_errors += len(errors) total_warnings += len(warnings) status = '✅ VALID' if not errors else '❌ INVALID' print(f'\n📄 {filepath} — {status} ' f'({len(errors)} ошибок, {len(warnings)} предупреждений)') if issues: for issue in issues: print(str(issue)) print(f"\n{'=' * 70}") if total_errors: print(f'Итого: {total_errors} ошибок, {total_warnings} предупреждений — INVALID ❌') sys.exit(1) else: if total_warnings: print(f'✅ Все обязательные проверки пройдены — VALID ' f'(с {total_warnings} предупреждениями)') else: print('✅ Все проверки пройдены — VALID') sys.exit(0) if __name__ == '__main__': main()