Investment Plans workspace
Open raw ↗
# -*- coding: utf-8 -*-
"""
tidy.py — file placement and debris enforcement.

OWNER: 01_System/tidy.py
REVIEW: whenever 01_System/portable/FILE_PLACEMENT_MAP.md changes.

GOV-F3.9  create the folder, never work around it   ->  --ensure
GOV-F3.10 no debris; sweep to 06_Archive/_debris/<date>/ with a hash manifest, never delete -> --sweep
GOV-F3.11 no links to live files outside the project
GOV-F3.12 a mechanical check fails on any of the above, and has been proven able to fail

Usage:
    python 01_System/tidy.py --report     list every violation, exit 1 if any
    python 01_System/tidy.py --ensure     create missing mandated folders
    python 01_System/tidy.py --sweep      archive debris with a manifest (nothing deleted)
"""
import os, re, sys, json, hashlib, datetime, shutil

ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))

MANDATED_FOLDERS = [
    "00_Handover", "01_System", "01_System/checkers", "01_System/portable",
    "01_System/portable/templates/business_plan",
    "02_Work", "02_Work/_scratch", "02_Work/diagrams", "02_Work/scratch",
    "03_Registers", "04_Inputs", "05_Outputs",
    "06_Archive", "06_Archive/_versions", "06_Archive/_superseded", "06_Archive/_debris",
    "07_Improvements", "08_Reusable_Knowledge", "09_Help_Hub",
]
ROOT_ALLOWED = {"README.md", "Dashboard.html"}

# GOV-F3.8: "A file that fits no row is a question for Zaid, never a new folder of your own
# invention." These top-level folders exist on Zaid's disk inside this project's root and were not
# created by this project. They are NOT quietly legitimised — each one is named here with the
# question it raises, the question is carried as an open action, and the tidy report prints it
# every run so it cannot fade into the background. Nothing here is moved: one of them is a folder
# separately connected to this session and moving it would break something outside this project.
FOREIGN_TOP = {
    "00_Governance framework":
        "Holds the v3.9 Standard (already filed in 05_Outputs) and WEBAPP_REFERENCE, which belongs "
        "to the AI Project Governance project and is separately connected to this session. "
        "ACT-014 asks Zaid where it should live. Not moved.",
    "Project":
        "Empty directory left by an earlier session. Not removed: this mount forbids deletion.",
    "Projects":
        "Empty directory left by an earlier session. Not removed: this mount forbids deletion.",
}

DEBRIS_PATTERNS = [
    (r"__pycache__$",              "python bytecode cache"),
    (r"\.pyc$",                    "python bytecode"),
    (r"\.(tmp|bak|orig)$",         "temporary or backup copy"),
    (r"~$",                        "editor backup"),
    (r"\.(lock|lck)$",             "lock file"),
    (r"\(\d+\)\.[A-Za-z0-9]+$",    "cloud-sync duplicate copy"),
    (r"-conflict",                 "cloud-sync conflict copy"),
    (r"(?i)\bcopy\b.*\.[A-Za-z0-9]+$", "duplicate copy"),
]
SECRET_PATTERNS = [
    (r"^\.env$", ".env secret store"),
    (r"\.(key|pem|p12|pfx)$", "key material"),
    (r"^id_rsa", "private key"),
]
LINK_PATTERNS = [(r"\.(lnk|url|webloc)$", "shortcut to a file outside the project")]

# Folders the map knows about, as top-level names
KNOWN_TOP = {f.split("/")[0] for f in MANDATED_FOLDERS}


def walk(skip_archive=True):
    for dp, dns, fns in os.walk(ROOT):
        rel = os.path.relpath(dp, ROOT)
        if rel == ".":
            rel = ""
        if skip_archive and rel.startswith("06_Archive"):
            dns[:] = []
            continue
        yield rel, dns, fns


def report():
    findings = []
    foreign = []

    # 1. mandated folders present
    for f in MANDATED_FOLDERS:
        if not os.path.isdir(os.path.join(ROOT, f)):
            findings.append(("MISSING FOLDER", f, "mandated by the placement map; run --ensure"))

    # 2. only README.md and Dashboard.html at the root
    for name in sorted(os.listdir(ROOT)):
        p = os.path.join(ROOT, name)
        if os.path.isfile(p) and name not in ROOT_ALLOWED and not name.startswith("."):
            findings.append(("LOOSE ROOT FILE", name, "only README.md and Dashboard.html may sit at the root"))
        if os.path.isdir(p) and name not in KNOWN_TOP and not name.startswith("."):
            if name in FOREIGN_TOP:
                foreign.append((name, FOREIGN_TOP[name]))
            else:
                findings.append(("UNKNOWN FOLDER", name,
                                 "the placement map does not know this folder; it is a question for "
                                 "Zaid (GOV-F3.8), not a new rule"))

    # 3. debris, secrets and links anywhere outside the archive
    for rel, dns, fns in walk():
        for d in list(dns):
            for pat, why in DEBRIS_PATTERNS:
                if re.search(pat, d):
                    findings.append(("DEBRIS", os.path.join(rel, d), why))
        for fn in fns:
            path = os.path.join(rel, fn) if rel else fn
            for pat, why in DEBRIS_PATTERNS:
                if re.search(pat, fn):
                    findings.append(("DEBRIS", path, why)); break
            for pat, why in SECRET_PATTERNS:
                if re.search(pat, fn):
                    findings.append(("SECRET STORE", path, why + " — must not exist in the project (INV-5)"))
            for pat, why in LINK_PATTERNS:
                if re.search(pat, fn):
                    findings.append(("LINK", path, why))
            full = os.path.join(ROOT, path)
            if os.path.islink(full):
                findings.append(("SYMLINK", path, "symbolic link to a file outside the project (GOV-F3.11)"))

    report.foreign = foreign
    # 4. a live-named instruction file inside the archive would be read as current
    arch = os.path.join(ROOT, "06_Archive")
    if os.path.isdir(arch):
        for dp, dns, fns in os.walk(arch):
            for fn in fns:
                if re.match(r"(?i)^(CLAUDE|AGENTS?|INSTRUCTIONS?|GEMINI)\.md$", fn):
                    findings.append(("ARCHIVED INSTRUCTION FILE",
                                     os.path.relpath(os.path.join(dp, fn), ROOT),
                                     "tools read nested instruction files as current; rename to *.archived"))
    return findings


def ensure():
    made = []
    for f in MANDATED_FOLDERS:
        p = os.path.join(ROOT, f)
        if not os.path.isdir(p):
            os.makedirs(p, exist_ok=True)
            made.append(f)
    return made


def sweep():
    """Archive debris with a hash manifest. Nothing is deleted (GOV-D1.7, GOV-F3.10)."""
    findings = [f for f in report() if f[0] in ("DEBRIS", "LINK", "SYMLINK")]
    if not findings:
        return None, []
    stamp = datetime.date.today().isoformat()
    dest = os.path.join(ROOT, "06_Archive", "_debris", stamp)
    os.makedirs(dest, exist_ok=True)
    manifest, moved = [], []
    for kind, path, why in findings:
        src = os.path.join(ROOT, path)
        if not os.path.exists(src):
            continue
        flat = path.replace(os.sep, "__")
        dst = os.path.join(dest, flat)
        if os.path.isdir(src):
            # Zip-then-delete fails on a mount that forbids unlink — which is exactly the mount
            # this project lives on when it is on Zaid's computer, so the sweep failed there and
            # the debris check stayed red through every run. Moving the directory is a rename, and
            # a rename is permitted where a delete is not. The archive is the moved directory
            # itself, which is a better record than a zip of it anyway.
            try:
                shutil.make_archive(dst, "zip", src)
                shutil.rmtree(src)
                dst = dst + ".zip"
            except (PermissionError, OSError):
                if os.path.exists(dst):
                    dst = dst + "_" + datetime.datetime.now().strftime("%H%M%S")
                shutil.move(src, dst)
        else:
            shutil.move(src, dst)
        if os.path.isdir(dst):
            h = hashlib.sha256()
            for dp2, _dn2, fn2 in os.walk(dst):
                for f2 in sorted(fn2):
                    h.update(open(os.path.join(dp2, f2), "rb").read())
            digest = h.hexdigest()
        else:
            digest = hashlib.sha256(open(dst, "rb").read()).hexdigest()
        manifest.append({"original_path": path, "archived_as": os.path.basename(dst),
                         "reason": why, "sha256": digest})
        moved.append(path)
    with open(os.path.join(dest, "MANIFEST.json"), "w", encoding="utf-8") as f:
        json.dump({"swept": stamp, "root": "06_Archive/_debris/" + stamp,
                   "note": "Nothing was deleted. Every entry carries the SHA-256 of the archived copy "
                           "so a rollback can be verified (GOV-F3.10).",
                   "files": manifest}, f, indent=2)
    return dest, moved


def main():
    args = set(sys.argv[1:]) or {"--report"}
    if "--ensure" in args:
        made = ensure()
        print("created %d missing folder(s)%s" % (len(made), (": " + ", ".join(made)) if made else ""))
    if "--sweep" in args:
        dest, moved = sweep()
        if dest:
            print("swept %d item(s) to %s with a hash manifest" % (len(moved), os.path.relpath(dest, ROOT)))
        else:
            print("nothing to sweep")
    findings = report()
    if "--report" in args or not (args - {"--report"}):
        if not findings:
            print("TIDY PASS — placement map satisfied: no loose root files, no debris, no secret stores, "
                  "no links to live files, no unknown folders, no missing mandated folders.")
        else:
            print("TIDY FAIL — %d finding(s):" % len(findings))
            for kind, path, why in findings:
                print("  %-24s %-52s %s" % (kind, path[:52], why))
        for name, why in getattr(report, "foreign", []):
            print("  NOTE  foreign top-level folder %-26s %s" % (name, why))
    sys.exit(1 if findings else 0)


if __name__ == "__main__":
    main()