/
ump-team
/
ump-infra
Обзор
Документация
Войти
/
ump-team
/
ump-infra
Код
Запросы
1
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
dev/test
scripts/detect_manual_changes.py
264 строки
11 KB
Dmitry Kochenov
fix: load_snapshot теперь сохраняет created_at при загрузке снапшота
09 авг 2026, 15:54
09 авг 2026, 15:54
1b34890
Код
Авторство
О чём код?
#!/usr/bin/env python3 """ Детектор ручных правок файлов проекта. Обнаруживает файлы, изменённые вручную (мимо AI-агента), и фиксирует их в логе. Принцип: - Git уже отслеживает изменения. Но git не отличает «агент изменил» от «пользователь изменил». - Этот скрипт сравнивает текущее состояние с последним «снимком» агента. - Снимок хранится в _meta/agent-snapshot.json — обновляется step-finalizer'ом после каждого шага. - Если файл изменился после снимка — это ручная правка. Запуск: uv run python scripts/detect_manual_changes.py uv run python scripts/detect_manual_changes.py --update-snapshot # обновить снимок Exit codes: 0 — ручных правок нет 0 + вывод списка — есть ручные правки (вывод в stdout, лог в файл) 1 — ошибка выполнения """ from __future__ import annotations import argparse import hashlib import json import os import sys from datetime import datetime from pathlib import Path # Единая точка правды для BASE-резолвинга. try: from ump.paths import detect_base except ImportError: _here = Path(__file__).resolve().parent sys.path.insert(0, str(_here.parent)) from ump.paths import detect_base # type: ignore[no-redef] BASE = detect_base(__file__) SNAPSHOT_FILE = BASE / '_meta' / 'agent-snapshot.json' MANUAL_CHANGES_LOG = BASE / '_meta' / 'manual-changes.log' # PEP 695: type-alias для читаемости. # P2-4 fix (mypy): ChangeSet теперь dict[str, FileSnapshot] — каждая категория # (added/modified/deleted) содержит dict[path → file_info]. type FileSnapshot = dict[str, dict[str, object]] type ChangeSet = dict[str, dict[str, dict[str, object]]] # Файлы и каталоги, которые НЕ считаются «ручными правками» (генерируются автоматически). EXCLUDE_PATTERNS: tuple[str, ...] = ( '_meta/step-progress.yaml', # обновляется оркестратором (legacy) '_meta/step-progress-*.yaml', # : per-step active progress '_meta/step-progress-*.done.yaml', # архив '_meta/agent-snapshot.json', # сам снимок '_meta/manual-changes.log', # сам лог '_meta/user-settings.local.yaml', # настройки пользователя '.git/', # git internals 'node_modules/', # зависимости '.venv/', # виртуальное окружение '__pycache__/', # Python cache '.pytest_cache/', # pytest cache '.ruff_cache/', # ruff cache 'docs/.vitepress/dist/', # build output 'docs/.vitepress/cache/', # build cache '.gigaide/', # IDE metadata '.idea/', # IntelliJ metadata '.vscode/', # VSCode metadata '*.log', # логи '*.lock', # lock-файлы (uv.lock, poetry.lock, ...) 'uv.lock', # явные имена lock-файлов (P11-B-4 fix) 'package-lock.json', # P11-B-4 fix (фаза 11, Неделя 2): # pattern '*.lock' через fnmatch НЕ матчит # 'package-lock.json' (нет .lock extension). # Добавляем явно. См. audit/REPORT.md. 'pnpm-lock.yaml', # аналогично для pnpm 'yarn.lock', # и yarn ) def should_exclude(path: str) -> bool: """Проверить, что путь исключён из анализа.""" import fnmatch return any(fnmatch.fnmatch(path, pattern) or path.startswith(pattern.rstrip('/')) for pattern in EXCLUDE_PATTERNS) def file_hash(filepath: Path) -> str | None: """Вычислить SHA-256 хэш файла. None если файл не читается. P1-15 fix: точечный except OSError вместо широкого Exception — ловит только файловые ошибки (FileNotFoundError, PermissionError, IsADirectoryError — все наследуются от OSError). """ try: with filepath.open('rb') as f: return hashlib.sha256(f.read()).hexdigest() except OSError: return None def collect_snapshot() -> FileSnapshot: """Собрать текущий снимок всех файлов проекта.""" snapshot: FileSnapshot = {} for root, dirs, files in os.walk(BASE): # Пропустить исключённые каталоги rel_root = Path(root).relative_to(BASE) rel_root_str = str(rel_root) + '/' if rel_root != Path('.') else '' if should_exclude(rel_root_str): continue # Фильтр dirs in-place dirs[:] = [d for d in dirs if not should_exclude(rel_root_str + d + '/')] for fname in files: full = Path(root) / fname rel = str(full.relative_to(BASE)) if should_exclude(rel): continue h = file_hash(full) if h: snapshot[rel] = { 'hash': h, 'mtime': full.stat().st_mtime, 'size': full.stat().st_size, } return snapshot def load_snapshot() -> FileSnapshot | None: """Загрузить предыдущий снимок. P1-15 fix: точечные except — OSError для чтения, json.JSONDecodeError для парсинга. """ if not SNAPSHOT_FILE.exists(): return None try: raw = json.loads(SNAPSHOT_FILE.read_text(encoding='utf-8')) if not isinstance(raw, dict): return None return {k: v for k, v in raw.items() if isinstance(k, str) and (isinstance(v, (dict, str)) or v is None)} # type: ignore[return-value] except (OSError, json.JSONDecodeError): return None def save_snapshot(snapshot: FileSnapshot) -> None: """Сохранить текущий снимок.""" SNAPSHOT_FILE.parent.mkdir(parents=True, exist_ok=True) data = { 'created_at': datetime.now().isoformat(), 'files': snapshot, } SNAPSHOT_FILE.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding='utf-8') def detect_changes(current: FileSnapshot, previous: FileSnapshot) -> ChangeSet: """Сравнить снимки. Вернуть {added, modified, deleted}.""" added: dict[str, dict[str, object]] = {} modified: dict[str, dict[str, object]] = {} deleted: dict[str, dict[str, object]] = {} for path, info in current.items(): if path not in previous: added[path] = info elif previous[path]['hash'] != info['hash']: modified[path] = { 'old_hash': previous[path]['hash'], 'new_hash': info['hash'], 'mtime': info['mtime'], } for path in previous: if path not in current: deleted[path] = previous[path] return {'added': added, 'modified': modified, 'deleted': deleted} def log_manual_changes(changes: ChangeSet) -> None: """Записать ручные правки в лог.""" MANUAL_CHANGES_LOG.parent.mkdir(parents=True, exist_ok=True) timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') with MANUAL_CHANGES_LOG.open('a', encoding='utf-8') as f: f.write(f'\n=== {timestamp} — обнаружены ручные правки ===\n') if changes['added']: f.write(f'\nДОБАВЛЕНО ({len(changes["added"])}):\n') for path in sorted(changes['added']): f.write(f' + {path}\n') if changes['modified']: f.write(f'\nИЗМЕНЕНО ({len(changes["modified"])}):\n') for path in sorted(changes['modified']): # P2-4 fix (mypy): явная аннотация — info это dict[str, str]. info: dict[str, str] = changes['modified'][path] # type: ignore[assignment] f.write(f' M {path}\n') f.write(f' old: {info["old_hash"][:16]}...\n') f.write(f' new: {info["new_hash"][:16]}...\n') if changes['deleted']: f.write(f'\nУДАЛЕНО ({len(changes["deleted"])}):\n') for path in sorted(changes['deleted']): f.write(f' - {path}\n') f.write('\n') print(f'Лог ручных правок: {MANUAL_CHANGES_LOG}') def main(): parser = argparse.ArgumentParser(description='Детектор ручных правок файлов') parser.add_argument('--update-snapshot', action='store_true', help='Обновить снимок (использовать после шага агента)') parser.add_argument('--quiet', action='store_true', help='Тихий режим — только exit code') args = parser.parse_args() current = collect_snapshot() if args.update_snapshot: save_snapshot(current) if not args.quiet: print(f'Снимок обновлён: {SNAPSHOT_FILE}') print(f'Файлов в снимке: {len(current)}') sys.exit(0) previous = load_snapshot() if previous is None: # Первый запуск — создай снимок, считай что изменений нет save_snapshot(current) if not args.quiet: print(f'Первый запуск. Снимок создан: {len(current)} файлов.') print('При следующем запуске будут видны ручные правки.') sys.exit(0) changes = detect_changes(current, previous['files']) # type: ignore[arg-type] total = len(changes['added']) + len(changes['modified']) + len(changes['deleted']) if total == 0: if not args.quiet: print('Ручных правок не обнаружено.') sys.exit(0) # Есть изменения if not args.quiet: print(f'Обнаружено ручных правок: {total}') print(f' Добавлено: {len(changes["added"])}') print(f' Изменено: {len(changes["modified"])}') print(f' Удалено: {len(changes["deleted"])}') print() if changes['modified']: print('Изменённые файлы:') for path in sorted(changes['modified'])[:20]: print(f' M {path}') if len(changes['modified']) > 20: print(f' ... и ещё {len(changes["modified"]) - 20}') log_manual_changes(changes) sys.exit(0) if __name__ == '__main__': main()