| |
| """Repo hygiene gate — keep internal working notes out of the public tree. |
| |
| The 2026-07-10 cleanup untracked the internal audits (root ``audit_*.md``) and |
| the session handoff (``docs/NEXT_SESSION.md``) that had been living in the |
| public repository (and, via the working-tree deploy, on the public HF Space). |
| |
| All of these are ignored via ``.gitignore`` now, but ``git add -f`` or a future |
| ``.gitignore`` edit could silently re-introduce them. This gate fails CI the |
| moment any such path shows up in ``git ls-files``, so the cleanup can't rot. |
| The HF Space is guarded separately: ``.deploy_hf.py`` uploads only tracked |
| files and prunes strays from the Space. |
| |
| Usage:: |
| |
| python scripts/check_repo_hygiene.py # scan tracked files, exit 1 on violations |
| python scripts/check_repo_hygiene.py --self-test # prove the detector works |
| """ |
|
|
| from __future__ import annotations |
|
|
| import argparse |
| import re |
| import subprocess |
| import sys |
|
|
| _DATE_NOTE = re.compile(r"^\d{2}_\d{2}_\d{2}\.md$") |
|
|
|
|
| def classify(path: str) -> str | None: |
| """Return a human-readable reason if *path* is forbidden, else ``None``. |
| |
| ``path`` is a forward-slash repo-relative path exactly as ``git ls-files`` |
| emits it. |
| """ |
| if "/" not in path: |
| if path.startswith("audit_") and path.endswith(".md"): |
| return "internal root-level audit report" |
| if path.startswith("plan_") and path.endswith(".md"): |
| return "internal root-level planning note" |
| if path.startswith("_ref_"): |
| return "internal root-level reference scratch" |
| if path.startswith("_NEXT_SESSION"): |
| return "internal session handoff (root entry point)" |
| if _DATE_NOTE.match(path): |
| return "internal root-level dated session note" |
| if path == "docs/NEXT_SESSION.md": |
| return "internal session handoff" |
| if path == ".claude" or path.startswith(".claude/"): |
| return "assistant workspace directory" |
| if path == "scratchpad" or path.startswith("scratchpad/"): |
| return "local scratch directory" |
| return None |
|
|
|
|
| def find_violations(paths: list[str]) -> list[tuple[str, str]]: |
| out: list[tuple[str, str]] = [] |
| for path in paths: |
| reason = classify(path) |
| if reason is not None: |
| out.append((path, reason)) |
| return out |
|
|
|
|
| def tracked_files() -> list[str]: |
| |
| |
| result = subprocess.run( |
| ["git", "-c", "core.quotepath=false", "ls-files"], |
| capture_output=True, |
| text=True, |
| check=True, |
| encoding="utf-8", |
| ) |
| return [line for line in result.stdout.splitlines() if line] |
|
|
|
|
| def self_test() -> int: |
| """Prove the detector flags forbidden paths and leaves legitimate ones alone.""" |
| must_flag = [ |
| "audit_codex_12_05_26.md", |
| "audit_kimi_25_05_26.md", |
| "plan_for_pres.md", |
| "_ref_presentation3.html", |
| "27_05_26.md", |
| "_NEXT_SESSION.md", |
| "docs/NEXT_SESSION.md", |
| ".claude/settings.json", |
| "scratchpad/tmp.py", |
| ] |
| must_pass = [ |
| "README.md", |
| "docs/SESSION_HANDOFF.md", |
| "docs/v18_residue_audit.md", |
| "scripts/audit_rescore.py", |
| "src/nl_sql/agent/nodes/plan_query.py", |
| "scripts/check_repo_hygiene.py", |
| "eval/reports/2026-05-11/G_planner-moderate-n99.json", |
| ] |
| failures: list[str] = [] |
| for path in must_flag: |
| if classify(path) is None: |
| failures.append(f"FAILED to flag forbidden path: {path!r}") |
| for path in must_pass: |
| reason = classify(path) |
| if reason is not None: |
| failures.append(f"FALSE POSITIVE on legitimate path: {path!r} -> {reason}") |
| if failures: |
| print("[repo-hygiene] self-test FAILED:", file=sys.stderr) |
| for line in failures: |
| print(f" - {line}", file=sys.stderr) |
| return 1 |
| print(f"[repo-hygiene] self-test passed ({len(must_flag)} flagged, {len(must_pass)} clean).") |
| return 0 |
|
|
|
|
| def main(argv: list[str] | None = None) -> int: |
| parser = argparse.ArgumentParser( |
| description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter |
| ) |
| parser.add_argument( |
| "--self-test", |
| action="store_true", |
| help="Verify the detector flags forbidden paths and passes clean ones, then exit.", |
| ) |
| args = parser.parse_args(argv) |
|
|
| if args.self_test: |
| return self_test() |
|
|
| paths = tracked_files() |
| violations = find_violations(paths) |
| if violations: |
| print( |
| f"[repo-hygiene] FAILED: {len(violations)} internal-only file(s) are tracked in the public repo:", |
| file=sys.stderr, |
| ) |
| for path, reason in violations: |
| print(f" {path} ({reason})", file=sys.stderr) |
| print( |
| "\nThese were untracked on 2026-07-10 and must stay out of the public tree. " |
| "Remove with `git rm --cached <path>` and confirm `.gitignore` still covers them.", |
| file=sys.stderr, |
| ) |
| return 1 |
|
|
| print(f"[repo-hygiene] OK: {len(paths)} tracked file(s), no internal-note leaks.") |
| return 0 |
|
|
|
|
| if __name__ == "__main__": |
| raise SystemExit(main()) |
|
|