Files
2026-10-18 16:21:54 +08:00

408 lines
18 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Code4History cross-version research-data migration (stdlib only).
Usage:
python migrate.py export --source OLD --out research.c4h.zip
python migrate.py restore --bundle research.c4h.zip --target NEW
python migrate.py upgrade --source OLD --template code4history-v0.8.zip --target NEW
This tool never deletes the old project, never overwrites existing user-edited
content in the target, and never installs captured framework customizations
as executable instructions. Review the migration report after every restore.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
from pathlib import Path, PurePosixPath
import shutil
import stat
import sys
import tempfile
from datetime import datetime, timezone
from zipfile import ZIP_DEFLATED, ZipFile, ZipInfo
BUNDLE_TYPE = 'code4history-research-export'
FORMAT_VERSION = 1
BASELINE_FILE = 'migration_baselines.json'
MUTABLE_ROOT = {'topic.md', 'USERINFO.md', 'STATE.md'}
MUTABLE_PATHS = {
'handoff/latest.md', '00_governance/bibliography.bib', '00_governance/checkpoints.md',
'00_governance/decisions.md', '00_governance/research-log.md',
'01_sources/catalog.md', '04_context/chronology.md',
'05_historiography/debates.md', '07_manuscript/outline.md',
'07_manuscript/sections/order.txt',
'08_review/citation-audit.md', '08_review/contradictions.md',
'08_review/counterevidence.md', '08_review/submission-checklist.md',
}
FRAMEWORK_ROOTS = {
'AGENTS.md', 'agent.md', 'AGENT.md', 'code4history.md', 'README.md',
'INTERPRETATION.md', 'ARGUMENT_DENSITY.md', 'SYNTHESIS_AND_PROSE.md',
'DISCUSSION_TO_TOPIC.md', 'QUICK_FIX.md', 'PUBLISHING.md', 'MIGRATION.md',
'CHANGELOG.md', '.gitignore', 'UPGRADE_v0.4.md', 'UPGRADE_v0.5.md',
'UPGRADE_v0.6.md', 'UPGRADE_v0.7.md', 'UPGRADE_v0.8.md', 'INTERACTION.md',
}
SKIP_DIRS = {
'.git', '.venv', 'venv', 'node_modules', '__pycache__', '.pytest_cache',
'.mypy_cache', '.ruff_cache', '.idea', '.vscode', '.code4history-migration',
}
SKIP_NAMES = {'.DS_Store', 'Thumbs.db', '.gitkeep'}
SELF_GENERATED_PATHS = {'tools/migration_baselines.json'}
BLOCKED_SUFFIXES = {'.pem', '.key', '.p12', '.pfx', '.credentials', '.pyc'}
def timestamp() -> str:
return datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')
def digest_bytes(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def digest_file(path: Path) -> str:
sha = hashlib.sha256()
with path.open('rb') as f:
for chunk in iter(lambda: f.read(1024 * 1024), b''):
sha.update(chunk)
return sha.hexdigest()
def validate_relpath(name: str) -> str:
# Treat archive paths as POSIX even on Windows; reject Windows drive/UNC.
if not isinstance(name, str) or not name or '\\' in name or ':' in name:
raise ValueError(f'非法相对路径:{name!r}')
p = PurePosixPath(name)
if p.is_absolute() or name.startswith('/') or any(s in ('', '.', '..') for s in name.split('/')):
raise ValueError(f'路径穿越或绝对路径:{name!r}')
return p.as_posix()
def is_sensitive(rel: str) -> bool:
p = PurePosixPath(rel)
if any(part in SKIP_DIRS for part in p.parts[:-1]):
return True
n = p.name
return n in SKIP_NAMES or n == '.env' or n.startswith('.env.') or p.suffix.lower() in BLOCKED_SUFFIXES
def baseline_data() -> dict:
source = Path(__file__).resolve().with_name(BASELINE_FILE)
if not source.is_file():
raise RuntimeError(f'缺少官方框架基线 {source};请将 migrate.py 与 migration_baselines.json 放在同一目录。')
doc = json.loads(source.read_text(encoding='utf-8'))
if doc.get('schema') != 1 or not isinstance(doc.get('files'), dict):
raise ValueError('不支持的框架基线格式')
return doc['files']
def is_framework_area(rel: str) -> bool:
p = PurePosixPath(rel)
if rel in FRAMEWORK_ROOTS or p.name.lower() == 'readme.md':
return True
return p.parts[0] in {'.agents', '10_templates', 'tools', 'tests'}
def classify(rel: str, sha: str, baselines: dict) -> str:
known = baselines.get(rel)
if rel in SELF_GENERATED_PATHS:
return 'stock'
if known and sha in known:
return 'stock' # exact byte match against official releases
if rel in MUTABLE_ROOT or rel in MUTABLE_PATHS:
return 'research'
# A shipped but modified document is a framework customization unless
# explicitly listed as a mutable research file above.
if known:
return 'framework_customization'
# All changes in framework / skill / tools areas require manual approval.
# Never silently install executable code or agent instructions.
if is_framework_area(rel):
return 'framework_customization'
return 'research'
def project_files(root: Path):
if not root.is_dir():
raise FileNotFoundError(f'项目目录不存在:{root}')
for base, dirs, files in os.walk(root, followlinks=False):
base_path = Path(base)
dirs[:] = [x for x in sorted(dirs) if x not in SKIP_DIRS and not (base_path / x).is_symlink()]
for name in sorted(files):
file = base_path / name
rel = file.relative_to(root).as_posix()
if is_sensitive(rel):
continue
if file.is_symlink() or not file.is_file():
print(f'警告:跳过软链接/非常规文件 {rel}', file=sys.stderr)
continue
validate_relpath(rel)
yield rel, file
def export(source: Path, output: Path, *, dry_run=False, quiet=False) -> dict:
source = source.resolve()
output = output.absolute()
if source == output or source in output.parents:
raise ValueError('导出包必须位于旧项目目录之外,避免循环打包或污染研究资料')
baselines = baseline_data()
manifest = {
'format': BUNDLE_TYPE, 'version': FORMAT_VERSION,
'created_utc': datetime.now(timezone.utc).isoformat(),
'source_dir_name': source.name, 'entries': [], 'excluded': [],
}
for rel, file in project_files(source):
sha = digest_file(file)
kind = classify(rel, sha, baselines)
if kind == 'stock':
continue
entry = {'path': rel, 'kind': kind, 'size': file.stat().st_size, 'sha256': sha}
manifest['entries'].append(entry)
if not quiet:
kinds = {k: sum(x['kind'] == k for x in manifest['entries']) for k in ('research', 'research_extension', 'framework_customization')}
print(f'导出计划:研究文件 {kinds["research"]},自定义扩展 {kinds["research_extension"]},需手动审阅的框架改动 {kinds["framework_customization"]}')
print('注意:.git 历史、.env/密钥、虚拟环境及缓存不在导出包内。原始 PDF 和研究产出会包含在内。')
if dry_run:
return manifest
output.parent.mkdir(parents=True, exist_ok=True)
tmp = None
try:
with tempfile.NamedTemporaryFile(prefix='.c4h-export-', suffix='.zip', dir=output.parent, delete=False) as f:
tmp = Path(f.name)
with ZipFile(tmp, 'w', compression=ZIP_DEFLATED, allowZip64=True, compresslevel=6) as archive:
for entry in manifest['entries']:
rel = entry['path']
category = 'research' if entry['kind'] != 'framework_customization' else 'framework_customizations'
archive.write(source.joinpath(*PurePosixPath(rel).parts), arcname=f'{category}/{rel}')
archive.writestr('manifest.json', json.dumps(manifest, ensure_ascii=False, indent=2) + '\n')
os.replace(tmp, output)
finally:
if tmp is not None and tmp.exists():
tmp.unlink()
if not quiet:
print(f'导出完成:{output}(旧项目未修改)')
return manifest
def read_bundle(bundle: Path):
with ZipFile(bundle, 'r') as z:
members = z.namelist()
if members.count('manifest.json') != 1 or len(set(members)) != len(members):
raise ValueError('迁移包中的清单不存在或有重复路径')
meta = json.loads(z.read('manifest.json'))
if meta.get('format') != BUNDLE_TYPE or meta.get('version') != FORMAT_VERSION:
raise ValueError('不是受支持的 Code4History 迁移包')
entries = meta.get('entries')
if not isinstance(entries, list):
raise ValueError('无效的清单内容')
listed = {'manifest.json'}
seen = set()
for item in entries:
if not isinstance(item, dict):
raise ValueError('无效的文件条目')
rel = validate_relpath(item['path'])
if rel in seen or item['kind'] not in ('research', 'research_extension', 'framework_customization'):
raise ValueError('重复文件或无效分类')
seen.add(rel)
pre = 'framework_customizations' if item['kind'] == 'framework_customization' else 'research'
archive_name = f'{pre}/{rel}'
if archive_name not in members:
raise ValueError(f'迁移包缺少文件 {archive_name}')
info = z.getinfo(archive_name)
if stat.S_ISLNK(info.external_attr >> 16):
raise ValueError(f'拒绝迁移包中的软链接 {rel}')
if info.file_size != item['size'] or len(item['sha256']) != 64:
raise ValueError(f'文件大小或校验值无效:{rel}')
listed.add(archive_name)
unknown = [x for x in members if x not in listed and not x.endswith('/')]
if unknown:
raise ValueError(f'迁移包存在未列入清单的文件:{unknown[:3]}')
return meta
def secure_target(target: Path, rel: str) -> Path:
validate_relpath(rel)
cur = target
for part in PurePosixPath(rel).parts:
cur = cur / part
if cur.is_symlink():
raise ValueError(f'目标路径含软链接,拒绝写入:{rel}')
return cur
def write_verified(zipobj: ZipFile, archive_name: str, dest: Path, entry: dict):
dest.parent.mkdir(parents=True, exist_ok=True)
temp = None
try:
with tempfile.NamedTemporaryFile(prefix='.c4h-', dir=dest.parent, delete=False) as output:
temp = Path(output.name)
sha = hashlib.sha256()
length = 0
with zipobj.open(archive_name, 'r') as incoming:
for chunk in iter(lambda: incoming.read(1024 * 1024), b''):
sha.update(chunk)
length += len(chunk)
output.write(chunk)
if sha.hexdigest() != entry['sha256'] or length != entry['size']:
raise ValueError(f'迁移包完整性校验失败:{entry["path"]}')
os.replace(temp, dest)
finally:
if temp is not None and temp.exists():
temp.unlink()
def restore(bundle: Path, target: Path, *, dry_run=False, quiet=False) -> dict:
meta = read_bundle(bundle)
baselines = baseline_data()
target = target.resolve()
if not target.is_dir() or not (target / 'code4history.md').is_file():
raise ValueError('目标目录必须是一个已解压、可读取 code4history.md 的新版框架')
# Preverify SHA256 for *all* payloads before writing anything.
with ZipFile(bundle, 'r') as z:
for item in meta['entries']:
rel, kind = item['path'], item['kind']
archive_name = ('framework_customizations' if kind == 'framework_customization' else 'research') + '/' + rel
sha = hashlib.sha256(); size = 0
with z.open(archive_name) as stream:
for part in iter(lambda: stream.read(1024*1024), b''):
sha.update(part); size += len(part)
if size != item['size'] or sha.hexdigest() != item['sha256']:
raise ValueError(f'迁移包损坏:{rel},未修改目标项目')
stamp = timestamp()
# Conflict file directory stays outside the tracked research directories.
review_root = target / '.code4history-migration' / 'conflicts' / stamp
operations = []
for item in meta['entries']:
rel = item['path']
dest = secure_target(target, rel)
if item['kind'] == 'framework_customization':
action = 'review-framework'
elif dest.exists():
if not dest.is_file():
action = 'conflict'
elif digest_file(dest) == item['sha256']:
action = 'unchanged'
elif digest_file(dest) in baselines.get(rel, []) and (rel in MUTABLE_PATHS or rel in MUTABLE_ROOT):
action = 'fill-stock-placeholder'
else:
action = 'conflict'
else:
action = 'copy-new'
operations.append((item, action))
summary = {k: sum(a == k for _, a in operations) for k in ('copy-new','fill-stock-placeholder','unchanged','conflict','review-framework')}
if not quiet:
print('恢复计划:' + ','.join(f'{k}={v}' for k, v in summary.items()))
if any(a in ('conflict', 'review-framework') for _, a in operations):
print('已有内容不会被覆盖:冲突与旧框架修改保存到 .code4history-migration/conflicts/ 中供人工处理。')
if dry_run:
return summary
report = {'source_bundle': str(bundle.resolve()), 'timestamp_utc': stamp, 'actions': []}
with ZipFile(bundle, 'r') as z:
for item, action in operations:
rel, kind = item['path'], item['kind']
srcname = ('framework_customizations' if kind == 'framework_customization' else 'research') + '/' + rel
if action == 'unchanged':
report['actions'].append({'path': rel, 'result': 'unchanged'})
continue
if action == 'review-framework':
dest = secure_target(target, f'.code4history-migration/conflicts/{stamp}/framework_customizations/{rel}')
elif action == 'conflict':
dest = secure_target(target, f'.code4history-migration/conflicts/{stamp}/incoming/{rel}')
else:
dest = secure_target(target, rel)
write_verified(z, srcname, dest, item)
report['actions'].append({'path': rel, 'result': action, 'saved_to': str(dest.relative_to(target))})
log = target / '.code4history-migration' / 'latest-report.json'
log.parent.mkdir(parents=True, exist_ok=True)
log.write_text(json.dumps(report, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
if not quiet:
print(f'恢复完成:{target};报告:{log}')
return summary
def initialize_template(template: Path, dest: Path):
"""Only create a new directory; extract safely from a known scaffold layout."""
if dest.exists():
raise FileExistsError(f'升级目标已存在:{dest}')
dest.parent.mkdir(parents=True, exist_ok=True)
stage = Path(tempfile.mkdtemp(prefix='.c4h-upgrade-', dir=dest.parent))
try:
with ZipFile(template, 'r') as z:
allfiles = [x for x in z.namelist() if not x.endswith('/')]
names = []
for name in allfiles:
# ZIP is expected to have a single code4history/ root.
if not name.startswith('code4history/'):
raise ValueError('不支持的模板根目录')
rel = validate_relpath(name[len('code4history/'):])
info = z.getinfo(name)
if stat.S_ISLNK(info.external_attr >> 16):
raise ValueError('框架模板不得包含软链接')
names.append((name, rel))
for name, rel in names:
output = secure_target(stage, rel)
output.parent.mkdir(parents=True, exist_ok=True)
with z.open(name) as incoming, output.open('wb') as f:
shutil.copyfileobj(incoming, f)
if not (stage/'code4history.md').is_file():
raise ValueError('新框架包缺少 code4history.md')
stage.rename(dest)
finally:
if stage.exists():
shutil.rmtree(stage)
def upgrade(source: Path, template: Path, target: Path, *, dry_run=False):
source, template, target = source.resolve(), template.resolve(), target.absolute()
if not source.is_dir() or not template.is_file():
raise ValueError('旧项目目录或新版框架 ZIP 不存在')
if source == target or source in target.parents or target in source.parents:
raise ValueError('升级目标应放在旧目录的平级位置,不得是旧目录自身或其内部目录')
if target.exists():
raise FileExistsError('目标已存在;请使用新的目标目录,或 export + restore 更新已有项目')
if dry_run:
export(source, target.with_suffix('.preview.zip'), dry_run=True)
return
with tempfile.TemporaryDirectory(prefix='c4h-bundle-') as d:
pkg = Path(d)/'research.c4h.zip'
export(source, pkg)
initialize_template(template, target)
try:
restore(pkg, target)
except Exception:
print(f'迁移中断,新目录仍保留用于检查:{target};旧项目未修改', file=sys.stderr)
raise
print(f'一键升级完成:{target};旧项目保留:{source}')
def main(argv=None):
parser = argparse.ArgumentParser(description='Code4History 跨版本研究数据迁移(只复制,不删旧研究)')
sub = parser.add_subparsers(dest='command', required=True)
a = sub.add_parser('export', help='从旧项目导出研究内容与框架个性化修改')
a.add_argument('--source', type=Path, required=True)
a.add_argument('--out', type=Path, required=True)
a.add_argument('--dry-run', action='store_true')
b = sub.add_parser('restore', help='恢复研究资料至已解压的新框架目录')
b.add_argument('--bundle', type=Path, required=True)
b.add_argument('--target', type=Path, required=True)
b.add_argument('--dry-run', action='store_true')
c = sub.add_parser('upgrade', help='一键创建新框架并恢复旧研究')
c.add_argument('--source', type=Path, required=True)
c.add_argument('--template', type=Path, required=True)
c.add_argument('--target', type=Path, required=True)
c.add_argument('--dry-run', action='store_true')
args = parser.parse_args(argv)
try:
if args.command == 'export': export(args.source, args.out, dry_run=args.dry_run)
elif args.command == 'restore': restore(args.bundle, args.target, dry_run=args.dry_run)
elif args.command == 'upgrade': upgrade(args.source, args.template, args.target, dry_run=args.dry_run)
except (ValueError, OSError, RuntimeError) as e:
print(f'错误:{e}', file=sys.stderr)
return 1
return 0
if __name__ == '__main__':
sys.exit(main())