#!/usr/bin/env python3 """Local source snapshot, UTF-8 ZIP and SHA-256 verification (Python stdlib).""" import argparse import hashlib import json import os from pathlib import Path import shutil import stat import zipfile SOURCE = Path(__file__).resolve().parents[1] ROOTS = ( 'AGENTS.md', 'PROJECT_PROGRESS.md', '新对话接手说明.md', '工具使用说明.md', '1-生成工作台.command', '1-生成工作台.bat', '2-求解与设计分析.command', '2-求解与设计分析.bat', '3-关卡查重器.command', '3-关卡查重器.bat', 'colorBlock', 'colorBlock-level-generator', 'colorBlock-similarity-checker', 'levels', '手动关卡', 'outputs', '1-50new', '1-50old', 'level8.json', 'scripts', ) EXCLUDED_NAMES = {'.git', '.DS_Store', '__MACOSX', 'node_modules', '__pycache__', '.pytest_cache', '.vscode', '.codebuddy', '.agents'} EXCLUDED_PATHS = {'colorBlock/library', 'colorBlock/temp', 'colorBlock/build', 'colorBlock/local', 'colorBlock/project.private.config.json', 'colorBlock-level-generator/settings/local.json'} def digest(path): h = hashlib.sha256() with path.open('rb') as stream: for chunk in iter(lambda: stream.read(1024 * 1024), b''): h.update(chunk) return h.hexdigest() def excluded(path, relative): if path.name in EXCLUDED_NAMES or relative in EXCLUDED_PATHS: return '缓存、依赖、本机配置或编辑器元数据' if path.is_symlink(): return '符号链接不跟随,避免引入包外内容' if path.name == '.env' or path.name.startswith('.env.') or path.suffix.lower() in {'.pem', '.key', '.p12'}: return '潜在私密配置/密钥不随包转移' return None def records(folder): result = [] for path in sorted(folder.rglob('*')): if path.is_file() and path.name != 'TRANSFER_MANIFEST.json': result.append({'path': path.relative_to(folder).as_posix(), 'bytes': path.stat().st_size, 'sha256': digest(path), 'mode': stat.S_IMODE(path.stat().st_mode)}) return result def verify(target): if target.is_dir(): manifest = json.loads((target / 'TRANSFER_MANIFEST.json').read_text(encoding='utf-8')) actual = records(target) assert actual == manifest['files'], '目录文件/校验值/权限与清单不一致' else: with zipfile.ZipFile(target) as archive: assert archive.testzip() is None, 'ZIP CRC错误' names = archive.namelist() roots = {name.split('/')[0] for name in names} assert len(roots) == 1, 'ZIP必须有唯一顶层项目目录' prefix = roots.pop() + '/' manifest = json.loads(archive.read(prefix + 'TRANSFER_MANIFEST.json')) expected = {prefix + entry['path'] for entry in manifest['files']} assert set(names) == expected | {prefix + 'TRANSFER_MANIFEST.json'}, 'ZIP存在缺失/额外文件' for entry in manifest['files']: name = prefix + entry['path'] assert '..' not in Path(entry['path']).parts and not Path(entry['path']).is_absolute() assert hashlib.sha256(archive.read(name)).hexdigest() == entry['sha256'], name info = archive.getinfo(name) assert info.file_size == entry['bytes'], name assert stat.S_IMODE(info.external_attr >> 16) == entry['mode'], name print(json.dumps({'verified': str(target), 'files': len(manifest['files']), 'bytes': sum(item['bytes'] for item in manifest['files'])}, ensure_ascii=False)) def build(destination): destination = destination.resolve() archive_path = destination.with_name(destination.name + '.zip') if destination.exists() or archive_path.exists(): raise SystemExit('目标已存在,拒绝覆盖;请选择新名称。') for root in ROOTS: if not (SOURCE / root).exists(): raise SystemExit('缺少必需交接内容:' + root) if destination == SOURCE / root or SOURCE / root in destination.parents: raise SystemExit('目标不可位于被复制的源子目录内。') destination.mkdir(parents=True) skipped = [] originals = [] def copy(path): relative = path.relative_to(SOURCE).as_posix() reason = excluded(path, relative) if reason: skipped.append({'path': relative, 'reason': reason}) return target = destination / relative if path.is_dir(): target.mkdir(parents=True, exist_ok=True) for child in sorted(path.iterdir()): copy(child) elif path.is_file(): target.parent.mkdir(parents=True, exist_ok=True) source_hash = digest(path) shutil.copy2(path, target) assert source_hash == digest(target) == digest(path), '复制中源文件变化:' + relative originals.append({'path': relative, 'sha256': source_hash}) for root in ROOTS: copy(SOURCE / root) markdown = sorted(path.relative_to(destination).as_posix() for path in destination.rglob('*.md')) index = ['# 转移包 Markdown 索引', '', '先读根目录“新对话接手说明.md”,再按导航阅读。历史报告可能包含已撤回结论,以PROJECT_PROGRESS最新状态为准。', '', '源文件MD数量:' + str(len(markdown)), ''] index.extend('- [' + path + '](<' + path + '>)' for path in markdown) (destination / 'DOCUMENT_INDEX.md').write_text('\n'.join(index) + '\n', encoding='utf-8') manifest = {'schema': 1, 'handoffVersion': '1.47.1', 'date': '2026-10-12', 'runtimeVersions': {'generatorAnalysis': '0.19.0', 'similarity': '1.1.1'}, 'scope': list(ROOTS), 'excluded': skipped, 'notes': ['ZIP/目录是本地快照,不会迁移对话内隐记忆。', '排除项只影响打包,未删除/修改原工作区内容。', '清单不递归校验自身;其余文件逐字节及权限均校验。', '包外MatchMaster/Downloads数据/编辑器程序未收录。'], 'sourceFiles': originals, 'files': records(destination)} (destination / 'TRANSFER_MANIFEST.json').write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + '\n', encoding='utf-8') verify(destination) with zipfile.ZipFile(archive_path, 'x', zipfile.ZIP_DEFLATED, compresslevel=6) as archive: for path in sorted(destination.rglob('*')): if path.is_file(): archive.write(path, destination.name + '/' + path.relative_to(destination).as_posix()) verify(archive_path) print(json.dumps({'zip': str(archive_path), 'zipBytes': archive_path.stat().st_size, 'zipSha256': digest(archive_path), 'sourceMarkdownFiles': len(markdown), 'excludedEntries': len(skipped)}, ensure_ascii=False)) if __name__ == '__main__': parser = argparse.ArgumentParser(description=__doc__) group = parser.add_mutually_exclusive_group(required=True) group.add_argument('--build', type=Path, help='新快照目录(拒绝覆盖)') group.add_argument('--verify', type=Path, help='校验已生成目录或ZIP') arguments = parser.parse_args() if arguments.build: build(arguments.build) else: verify(arguments.verify)