#!/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())