from __future__ import annotations import argparse import hashlib import json from datetime import datetime from pathlib import Path REPOSITORY_ROOT = Path(__file__).resolve().parents[2] DIRECTORIES = ( "backend", "config", "static", "tests", "tools", "vendor", "游资skills", ) ROOT_FILES = ( ".dockerignore", ".env.example", ".gitignore", "advanced_strategies.py", "alert_service.py", "api_access.py", "app_config.py", "ARCHITECTURE.md", "assistant_agent.py", "chart_data_provider.py", "compose.yaml", "database.py", "demo_data.py", "Dockerfile", "DOCKER_DEPLOY.md", "heaven_agent.py", "heaven_engine.py", "ifind_client.py", "llm_strategy.py", "llm_stream.py", "market_insights.py", "mentor_agent.py", "package-lock.json", "package.json", "playwright.config.js", "README.md", "realtime_aggregator.py", "requirements.txt", "screener.py", "security.py", "sentiment_engine.py", "server.py", "strategy_tracking.py", "sync_data.py", "THIRD_PARTY_NOTICES.md", "trade_journal.py", "tushare_client.py", ) def digest(path: Path) -> str: checksum = hashlib.sha256() with path.open("rb") as handle: for block in iter(lambda: handle.read(1024 * 1024), b""): checksum.update(block) return checksum.hexdigest() def display_path(path: Path, base: Path) -> str: try: return path.relative_to(base).as_posix() or "." except ValueError: return path.as_posix() def source_files(source_root: Path) -> list[Path]: files = [source_root / name for name in ROOT_FILES] for directory in DIRECTORIES: root = source_root / directory if not root.is_dir(): continue files.extend( path for path in root.rglob("*") if path.is_file() and "__pycache__" not in path.parts ) return sorted( set(files), key=lambda path: path.relative_to(source_root).as_posix() ) def build_manifest( source_root: Path, target_root: Path, source_commit: str, ) -> dict[str, object]: assets = [] mismatches = [] for source in source_files(source_root): relative = source.relative_to(source_root) target = target_root / relative source_exists = source.is_file() source_hash = digest(source) if source_exists else "" target_hash = digest(target) if target.is_file() else "" if not source_exists: status = "missing_source" elif not target.is_file(): status = "missing_target" elif source_hash == target_hash: status = "identical" else: status = "mismatch" if status != "identical": mismatches.append(relative.as_posix()) assets.append( { "source": relative.as_posix(), "target": display_path(target, REPOSITORY_ROOT), "bytes": source.stat().st_size if source_exists else 0, "sha256": source_hash, "disposition": "original_copy_pending_move", "status": status, } ) return { "schema_version": 1, "generated_at": datetime.now().astimezone().isoformat(timespec="seconds"), "source_commit": source_commit, "source_root": display_path(source_root, REPOSITORY_ROOT), "target_root": display_path(target_root, REPOSITORY_ROOT), "excluded": [ "next/", "data/review.db and SQLite sidecars", "data/private-mentor-skills/", ".env", "node_modules/", "logs, caches and generated test results", ], "asset_count": len(assets), "mismatch_count": len(mismatches), "mismatches": mismatches, "assets": assets, } def main() -> int: parser = argparse.ArgumentParser( description=( "Build an exact-copy manifest for a preservation migration stage. " "This is a migration-only tool; it does not describe the final moved layout." ) ) parser.add_argument("--source-root", type=Path, default=REPOSITORY_ROOT) parser.add_argument("--target-root", type=Path, default=REPOSITORY_ROOT / "app") parser.add_argument("--source-commit", default="") parser.add_argument( "--output", type=Path, required=True, help="write to a new audit path; do not overwrite committed slice evidence", ) args = parser.parse_args() source_root = args.source_root.resolve() target_root = args.target_root.resolve() output = args.output.resolve() manifest = build_manifest(source_root, target_root, args.source_commit) output.parent.mkdir(parents=True, exist_ok=True) output.write_text( json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", ) print( f"assets={manifest['asset_count']} mismatches={manifest['mismatch_count']} " f"output={output}" ) return 1 if manifest["mismatch_count"] else 0 if __name__ == "__main__": raise SystemExit(main())