Spaces:
Sleeping
Sleeping
| #!/usr/bin/env python3 | |
| """P1-T06: Generate a normalized source/build manifest for clean-build equivalence. | |
| Produces a deterministic SHA-256 inventory of source files (excluding caches, | |
| secrets, and generated artifacts). Two clean checkouts of the same revision | |
| should produce identical manifests for all tracked source paths. | |
| Usage: | |
| python scripts/clean_build_manifest.py [project_root] > build_manifest.sha256 | |
| python scripts/clean_build_manifest.py --compare a.sha256 b.sha256 | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import hashlib | |
| import os | |
| import sys | |
| from pathlib import Path | |
| SKIP_DIR_NAMES = { | |
| ".git", "__pycache__", ".pytest_cache", "node_modules", ".venv", "venv", | |
| "Temp", ".runtime_audit", "dist", "build", | |
| } | |
| SKIP_SUFFIXES = {".pyc", ".pyo", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".zip", ".tar", ".gz", ".log"} | |
| SKIP_FILES = {".env", "paper_positions.json"} | |
| def should_skip(path: Path, root: Path) -> bool: | |
| rel = path.relative_to(root) | |
| parts = set(rel.parts) | |
| if parts & SKIP_DIR_NAMES: | |
| return True | |
| if path.suffix.lower() in SKIP_SUFFIXES: | |
| return True | |
| if path.name in SKIP_FILES or path.name.startswith(".env."): | |
| return True | |
| return False | |
| def file_sha256(path: Path) -> str: | |
| h = hashlib.sha256() | |
| with open(path, "rb") as f: | |
| for chunk in iter(lambda: f.read(65536), b""): | |
| h.update(chunk) | |
| return h.hexdigest() | |
| def generate(root: Path) -> list[str]: | |
| lines = [] | |
| for dirpath, dirnames, filenames in os.walk(root): | |
| dirnames[:] = sorted(d for d in dirnames if d not in SKIP_DIR_NAMES) | |
| for fn in sorted(filenames): | |
| p = Path(dirpath) / fn | |
| if should_skip(p, root): | |
| continue | |
| rel = p.relative_to(root).as_posix() | |
| try: | |
| digest = file_sha256(p) | |
| except OSError: | |
| continue | |
| lines.append(f"{digest} {rel}") | |
| return lines | |
| def compare(a: Path, b: Path) -> int: | |
| la = a.read_text(encoding="utf-8").splitlines() | |
| lb = b.read_text(encoding="utf-8").splitlines() | |
| sa, sb = set(la), set(lb) | |
| only_a = sorted(sa - sb) | |
| only_b = sorted(sb - sa) | |
| if not only_a and not only_b and len(la) == len(lb): | |
| print(f"EQUIVALENT: {len(la)} entries match") | |
| return 0 | |
| print(f"DIVERGED: only_in_a={len(only_a)} only_in_b={len(only_b)}") | |
| for x in only_a[:20]: | |
| print(f" - {x}") | |
| for x in only_b[:20]: | |
| print(f" + {x}") | |
| return 1 | |
| def main() -> int: | |
| ap = argparse.ArgumentParser() | |
| ap.add_argument("root", nargs="?", default=".") | |
| ap.add_argument("--compare", nargs=2, metavar=("A", "B")) | |
| args = ap.parse_args() | |
| if args.compare: | |
| return compare(Path(args.compare[0]), Path(args.compare[1])) | |
| root = Path(args.root).resolve() | |
| for line in generate(root): | |
| print(line) | |
| return 0 | |
| if __name__ == "__main__": | |
| sys.exit(main()) | |