File size: 2,962 Bytes
2e658e7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
#!/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())