/
kochenov
/
universal-modular
Обзор
Документация
Войти
/
kochenov
/
universal-modular
Код
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
scripts/detect_manual_changes.py
231 строка
9 KB
Kochenov Dmitry
feat(foundation): Шаг 1.0 — Verify clone + создать STEP_STATE.md из template
10 июл 2026, 10:27
10 июл 2026, 10:27
8a5d435
Код
Авторство
О чём код?
#!/usr/bin/env python3 """ Детектор ручных правок файлов проекта (аудит v8). Обнаруживает файлы, изменённые вручную (мимо 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 — ошибка выполнения """ import argparse import hashlib import json import os import sys from datetime import datetime from pathlib import Path BASE = Path(__file__).parent.parent SNAPSHOT_FILE = BASE / '_meta' / 'agent-snapshot.json' MANUAL_CHANGES_LOG = BASE / '_meta' / 'manual-changes.log' # Файлы и каталоги, которые НЕ считаются «ручными правками» (генерируются автоматически) EXCLUDE_PATTERNS = [ '_meta/step-progress.yaml', # обновляется оркестратором '_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, package-lock.json) ] def should_exclude(path: str) -> bool: """Проверить, что путь исключён из анализа.""" import fnmatch for pattern in EXCLUDE_PATTERNS: if fnmatch.fnmatch(path, pattern) or path.startswith(pattern.rstrip('/')): return True return False def file_hash(filepath: Path) -> str | None: """Вычислить SHA-256 хэш файла. None если файл не читается.""" try: with open(filepath, 'rb') as f: return hashlib.sha256(f.read()).hexdigest() except Exception: return None def collect_snapshot() -> dict: """Собрать текущий снимок всех файлов проекта.""" snapshot = {} 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() -> dict | None: """Загрузить предыдущий снимок.""" if not SNAPSHOT_FILE.exists(): return None try: return json.loads(SNAPSHOT_FILE.read_text(encoding='utf-8')) except Exception: return None def save_snapshot(snapshot: dict) -> 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: dict, previous: dict) -> dict: """Сравнить снимки. Вернуть {added, modified, deleted}.""" added = {} modified = {} deleted = {} 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: dict) -> None: """Записать ручные правки в лог.""" MANUAL_CHANGES_LOG.parent.mkdir(parents=True, exist_ok=True) timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') with open(MANUAL_CHANGES_LOG, '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']): info = changes['modified'][path] 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']) 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()