#!/usr/bin/env python3 """repo-hygiene-audit — mechanical hygiene sweep across project domains. Read-only. Scans a set of domain roots, treating each immediate subdirectory as a repo (plus any explicit repo paths), and runs the mechanical checks that surfaced findings in the 2026-05-19/20 documentation/repo audit: * git auditability + .git ownership drift (+ dubious-ownership detection) * .env file permissions (target 0660, group devprojects) * inline secrets committed in compose files * missing / broken .git, non-git directories holding real content * uv-init leftover stubs (hello.py/main.py + pyproject/uv.lock/.python-version) * docker-compose.yml -> compose.yml naming drift * branch state + master-vs-main default-branch mismatch * dirty working trees, zero-commit repos, unpushed commits, stray worktrees * herbygitea SSH-alias residue * stale review docs (GEMINI.md / *-review-*.md) and doc presence/role split This script NEVER writes to the trees it scans. The only filesystem write is the optional findings report written to --output. Pure standard library (no pip deps) so it runs anywhere uv/python3 is present. """ from __future__ import annotations import argparse import datetime as dt import grp import json import os import pwd import re import socket import subprocess import sys from dataclasses import asdict, dataclass, field from pathlib import Path # --------------------------------------------------------------------------- # # Configuration # --------------------------------------------------------------------------- # # A domain either scans the immediate children of one or more roots ("scan"), # or names explicit repo paths ("repos"), or both. Override with --config. # # The Obsidian vault (wiki-vault, wiki-context) is deliberately NOT a default # domain: it's an MCP-owned, managed surface (the vault MCP maintains its # context), so a second scanner poking at it risks interference. Point --scan # or --repo at it manually if you ever truly need to. DEFAULT_DOMAINS: dict[str, dict[str, list[str]]] = { "homelab": {"scan": ["/opt/projects/homelab"]}, "pbs": {"scan": ["/opt/projects/pbs"]}, "docker": {"scan": ["/opt/projects/docker", "/home/herbyadmin/projects/docker"]}, } # Conventions from the repo-hygiene-remediation plan (Locked Decisions). EXPECTED_GROUP = "devprojects" ENV_MODE_OK = {0o600, 0o660} PREFERRED_DEFAULT_BRANCH = "main" # Repos in the "docker" domain are compose stacks: README only, no CLAUDE.md. COMPOSE_STACK_DOMAINS = {"docker"} # Directory names we never descend into / never treat as a repo. SKIP_NAMES = {".git", "__pycache__", "node_modules", ".venv", "venv"} STRAY_DIR_RE = re.compile(r"\.broken$|\.backup-\d|\.bak$|\.old$") # Secret-ish keys for the inline-compose-secret heuristic. SECRET_KEY_RE = re.compile( r"(SECRET|PASSWORD|PASSWD|TOKEN|API[_-]?KEY|PRIVATE[_-]?KEY|JWT|" r"ENCRYPTION|SALT|CREDENTIAL)", re.IGNORECASE, ) # A literal value that looks like a real secret (long hex / base64-ish), # as opposed to an env reference (${FOO}) or an obvious placeholder. SECRET_VAL_RE = re.compile(r"^[A-Za-z0-9+/=_-]{16,}$") PLACEHOLDER_RE = re.compile( r"^(changeme|change_me|example|placeholder|your[_-]|xxx+|todo|none|null|" r"true|false|secret|password)$", re.IGNORECASE, ) SEVERITY_ORDER = {"high": 0, "medium": 1, "low": 2, "info": 3} # Category -> project phase (for the markdown / phase-mapped report). CATEGORY_PHASE = { "SECURITY": "Phase 2 — Security remediation", "AUDITABILITY": "Phase 3 — Unblock un-auditable repos", "CLEANUP": "Phase 4 — Cross-repo cleanups", "GIT_STATE": "Git state (review before commit/merge)", "DOCS": "Docs (audit follow-up)", } # --------------------------------------------------------------------------- # # Data model # --------------------------------------------------------------------------- # @dataclass class Finding: domain: str repo: str path: str category: str severity: str check: str detail: str hint: str = "" @property def key(self) -> str: return f"{self.domain}/{self.repo}:{self.check}" @dataclass class RepoReport: domain: str name: str path: str is_git: bool = False owner: str = "" git_owner: str = "" group: str = "" branch: str = "" findings: list[Finding] = field(default_factory=list) # --------------------------------------------------------------------------- # # Low-level helpers (all read-only) # --------------------------------------------------------------------------- # def run_git(repo: Path, *args: str) -> tuple[int, str, str]: """Run a git command against repo, never prompting. Returns (rc, out, err).""" env = dict(os.environ, GIT_TERMINAL_PROMPT="0", GIT_PAGER="cat", PAGER="cat") try: # git is resolved from PATH and all args are internally constructed # (no user-supplied shell input) — read-only subcommands only. proc = subprocess.run( # noqa: S603 ["git", "-C", str(repo), *args], # noqa: S607 capture_output=True, text=True, env=env, timeout=30, check=False, ) except (OSError, subprocess.SubprocessError) as exc: # pragma: no cover return 1, "", str(exc) return proc.returncode, proc.stdout.strip(), proc.stderr.strip() def owner_of(path: Path) -> str: try: return pwd.getpwuid(path.stat().st_uid).pw_name except (KeyError, OSError): try: return str(path.stat().st_uid) except OSError: return "?" def group_of(path: Path) -> str: try: return grp.getgrgid(path.stat().st_gid).gr_name except (KeyError, OSError): try: return str(path.stat().st_gid) except OSError: return "?" def has_real_content(repo: Path) -> bool: """True if the dir holds anything other than dotfiles/skip dirs.""" try: for child in repo.iterdir(): if child.name in SKIP_NAMES: continue if child.name.startswith("."): continue return True except OSError: return False return False # --------------------------------------------------------------------------- # # Individual checks — each appends Finding objects to rep.findings # --------------------------------------------------------------------------- # def check_git_and_ownership(rep: RepoReport) -> None: repo = Path(rep.path) rep.owner = owner_of(repo) rep.group = group_of(repo) git_path = repo / ".git" rep.is_git = git_path.exists() if rep.group not in (EXPECTED_GROUP, "?"): rep.findings.append( Finding( rep.domain, rep.name, rep.path, "AUDITABILITY", "low", "group-drift", f"directory group is '{rep.group}', expected '{EXPECTED_GROUP}'", f"chgrp -R {EXPECTED_GROUP} (and set the setgid bit)", ) ) if not rep.is_git: if has_real_content(repo): rep.findings.append( Finding( rep.domain, rep.name, rep.path, "AUDITABILITY", "high", "no-git", "directory holds real content but has no .git", "git init + initial commit (gitignore .env from the start), " "then doc it", ) ) return rep.git_owner = owner_of(git_path) if rep.git_owner != rep.owner: rep.findings.append( Finding( rep.domain, rep.name, rep.path, "AUDITABILITY", "medium", "git-owner-drift", f".git owned by '{rep.git_owner}' but dir owned by '{rep.owner}'", "chown -R the repo to one user; prefer a scoped " "safe.directory entry over the '*' wildcard", ) ) # Dubious-ownership shows up on any git invocation's stderr. rc, _out, err = run_git(repo, "rev-parse", "--is-inside-work-tree") if "dubious ownership" in err.lower(): rep.findings.append( Finding( rep.domain, rep.name, rep.path, "AUDITABILITY", "high", "dubious-ownership", "git refuses the repo: detected dubious ownership", "fix ownership (chown) OR add a SCOPED " "'safe.directory ' — avoid the wildcard, it disables " "the safety check everywhere", ) ) def check_branch_state(rep: RepoReport) -> None: if not rep.is_git: return repo = Path(rep.path) rc, head, _ = run_git(repo, "symbolic-ref", "--short", "HEAD") if rc == 0: rep.branch = head else: rc2, desc, _ = run_git(repo, "rev-parse", "--short", "HEAD") rep.branch = f"(detached @ {desc})" if rc2 == 0 else "(unknown)" # zero-commit repo (everything untracked) rc, count, _ = run_git(repo, "rev-list", "--count", "HEAD") if rc != 0: rep.findings.append( Finding( rep.domain, rep.name, rep.path, "AUDITABILITY", "high", "no-commits", "git repo with no commits yet (everything untracked)", "make an initial commit pass, then audit docs", ) ) return # nothing more to inspect on an empty repo # default-branch naming: prefer main over master _, branches_raw, _ = run_git(repo, "branch", "--format=%(refname:short)") branches = [b.strip() for b in branches_raw.splitlines() if b.strip()] if "master" in branches and "main" not in branches: rep.findings.append( Finding( rep.domain, rep.name, rep.path, "GIT_STATE", "medium", "master-default", f"default branch is 'master' (preferred: " f"'{PREFERRED_DEFAULT_BRANCH}')", "git branch -m master main (+ update remote default if pushed)", ) ) # leftover docs-audit branches from the one-time sweep audit_branches = [b for b in branches if b.startswith("docs-audit")] if audit_branches: rep.findings.append( Finding( rep.domain, rep.name, rep.path, "GIT_STATE", "low", "docs-audit-branch", f"leftover audit branch(es): {', '.join(audit_branches)}", "merge/push then delete once Travis has reviewed", ) ) # dirty working tree _, porcelain, _ = run_git(repo, "status", "--porcelain") dirty = [ln for ln in porcelain.splitlines() if ln.strip()] if dirty: rep.findings.append( Finding( rep.domain, rep.name, rep.path, "GIT_STATE", "low", "dirty-tree", f"{len(dirty)} uncommitted change(s) in the working tree", "commit / stash before auditing or sequencing a doc commit", ) ) # stray worktrees _, wt_raw, _ = run_git(repo, "worktree", "list", "--porcelain") worktrees = [ln for ln in wt_raw.splitlines() if ln.startswith("worktree ")] if len(worktrees) > 1: rep.findings.append( Finding( rep.domain, rep.name, rep.path, "GIT_STATE", "info", "extra-worktrees", f"{len(worktrees)} worktrees attached", "git worktree prune / remove if any are stale", ) ) # unpushed / no-upstream rc_up, _, _ = run_git(repo, "rev-parse", "--abbrev-ref", "@{u}") if rc_up != 0: rep.findings.append( Finding( rep.domain, rep.name, rep.path, "GIT_STATE", "info", "no-upstream", f"branch '{rep.branch}' has no upstream configured", "expected for unpushed audit work; push when Travis is ready", ) ) else: _, ahead, _ = run_git(repo, "rev-list", "--count", "@{u}..HEAD") if ahead.isdigit() and int(ahead) > 0: rep.findings.append( Finding( rep.domain, rep.name, rep.path, "GIT_STATE", "info", "unpushed-commits", f"{ahead} commit(s) ahead of upstream (unpushed)", "push when ready (manual for GitHub-destined repos)", ) ) def _iter_env_files(repo: Path) -> list[Path]: found: list[Path] = [] if not repo.is_dir(): return found bases = [repo, *[d for d in repo.iterdir() if d.is_dir()]] for base in bases: if base.name in SKIP_NAMES: continue try: for f in base.iterdir(): if not f.is_file(): continue n = f.name if n == ".env" or (n.startswith(".env.") and not n.endswith( (".example", ".sample", ".template", ".dist") )): found.append(f) except OSError: continue return found def check_env_perms(rep: RepoReport) -> None: for env in _iter_env_files(Path(rep.path)): try: st = env.stat() except OSError: continue mode = st.st_mode & 0o777 grpname = group_of(env) rel = env.relative_to(rep.path) world_readable = bool(mode & 0o004) if world_readable: rep.findings.append( Finding( rep.domain, rep.name, rep.path, "SECURITY", "high", f"env-world-readable:{rel}", f"{rel} is world-readable (mode {oct(mode)})", "chmod 0660 (or 0600) the .env file", ) ) elif mode not in ENV_MODE_OK: rep.findings.append( Finding( rep.domain, rep.name, rep.path, "SECURITY", "medium", f"env-mode:{rel}", f"{rel} mode is {oct(mode)} (target 0660 or 0600)", "chmod 0660 the .env file", ) ) if grpname not in (EXPECTED_GROUP, "?") and not world_readable: rep.findings.append( Finding( rep.domain, rep.name, rep.path, "SECURITY", "low", f"env-group:{rel}", f"{rel} group is '{grpname}', expected '{EXPECTED_GROUP}'", f"chgrp {EXPECTED_GROUP} the .env file", ) ) def check_compose(rep: RepoReport) -> None: repo = Path(rep.path) legacy = list(repo.glob("docker-compose.y*ml")) modern = list(repo.glob("compose.y*ml")) if legacy and modern: rep.findings.append( Finding( rep.domain, rep.name, rep.path, "CLEANUP", "medium", "compose-rename-midflight", "both docker-compose.yml and compose.yml present (rename " "mid-flight)", "git rm the legacy docker-compose.yml, keep compose.yml", ) ) elif legacy: rep.findings.append( Finding( rep.domain, rep.name, rep.path, "CLEANUP", "low", "compose-legacy-name", f"legacy '{legacy[0].name}' naming (prefer compose.yml)", "git mv docker-compose.yml compose.yml", ) ) # inline-secret heuristic across whatever compose files exist for cf in legacy + modern: try: lines = cf.read_text(errors="replace").splitlines() except OSError: continue hits: list[str] = [] for ln in lines: stripped = ln.strip() if not stripped or stripped.startswith("#"): continue # only key: value or key=value forms m = re.match(r"-?\s*([A-Za-z0-9_]+)\s*[:=]\s*(.+)$", stripped) if not m: continue key, val = m.group(1), m.group(2).strip().strip("\"'") if not SECRET_KEY_RE.search(key): continue if "${" in val or val.startswith("$"): continue # env reference — the right pattern if PLACEHOLDER_RE.match(val): continue if SECRET_VAL_RE.match(val): hits.append(key) if hits: uniq = sorted(set(hits)) rep.findings.append( Finding( rep.domain, rep.name, rep.path, "SECURITY", "high", f"inline-secret:{cf.name}", f"{cf.name} appears to inline secret literal(s): " f"{', '.join(uniq)}", "extract to .env (gitignored); rotate if ever public", ) ) def check_uv_stub(rep: RepoReport) -> None: repo = Path(rep.path) entry_stub = (repo / "main.py").is_file() or (repo / "hello.py").is_file() has_pyproject = (repo / "pyproject.toml").is_file() has_uv_residue = (repo / "uv.lock").is_file() or ( repo / ".python-version" ).is_file() if not (entry_stub and has_pyproject and has_uv_residue): return # Real Python project? src/ layout or a package dir or many .py files. if (repo / "src").is_dir(): return py_files = [ p for p in repo.rglob("*.py") if SKIP_NAMES.isdisjoint(set(p.parts)) and not p.name.startswith("test") ] if len(py_files) > 2: return # looks like a genuine Python codebase residue = [ n for n in ("main.py", "hello.py", "pyproject.toml", "uv.lock", ".python-version") if (repo / n).exists() ] rep.findings.append( Finding( rep.domain, rep.name, rep.path, "CLEANUP", "low", "uv-init-stub", "uv-init leftover stub in a non-Python repo: " + ", ".join(residue), "sweep-remove the uv-init residue if it's not on the build path", ) ) def check_stale_review_docs(rep: RepoReport) -> None: repo = Path(rep.path) stale: list[str] = [] for f in repo.iterdir() if repo.is_dir() else []: if not f.is_file(): continue n = f.name if n == "GEMINI.md" or re.search(r"review.*\.md$", n, re.IGNORECASE): if n.upper() in {"README.MD", "CLAUDE.MD"}: continue stale.append(n) if stale: rep.findings.append( Finding( rep.domain, rep.name, rep.path, "CLEANUP", "low", "stale-review-doc", "stray review artifact(s) at repo root: " + ", ".join( sorted(stale) ), "remove or move into a reviews/ subdir", ) ) def check_herbygitea_residue(rep: RepoReport) -> None: repo = Path(rep.path) # This skill's own tree (script + docs) names the alias as a detection # string; never let the audit flag itself. skill_tree = Path(__file__).resolve().parents[2] if repo.resolve() == skill_tree: return hits: list[str] = [] for p in repo.rglob("*"): if not p.is_file(): continue if not SKIP_NAMES.isdisjoint(set(p.parts)): continue if p.stat().st_size > 1_000_000: continue try: text = p.read_text(errors="ignore") except OSError: continue if "herbygitea" in text: hits.append(str(p.relative_to(repo))) if len(hits) >= 10: break if hits: rep.findings.append( Finding( rep.domain, rep.name, rep.path, "CLEANUP", "medium", "herbygitea-residue", "references the removed 'herbygitea' SSH alias in: " + ", ".join(hits[:10]), "update remotes/config/scripts to the canonical gitea host", ) ) def check_docs(rep: RepoReport) -> None: if not rep.is_git: return # non-git dirs are handled by the auditability check first repo = Path(rep.path) has_readme = (repo / "README.md").is_file() has_claude = (repo / "CLAUDE.md").is_file() if not has_readme: rep.findings.append( Finding( rep.domain, rep.name, rep.path, "DOCS", "medium", "missing-readme", "no README.md (human onboarding doc)", "add a README — human onboarding per the doc-role split", ) ) # docker compose stacks intentionally get README only, no CLAUDE.md. if not has_claude and rep.domain not in COMPOSE_STACK_DOMAINS: rep.findings.append( Finding( rep.domain, rep.name, rep.path, "DOCS", "medium", "missing-claude", "no CLAUDE.md (agent operating guide)", "add a CLAUDE.md from the canonical skeleton", ) ) # doc-role-split heuristic: an oversized CLAUDE.md is often a README clone. if has_claude: try: n_lines = len( (repo / "CLAUDE.md").read_text(errors="replace").splitlines() ) except OSError: n_lines = 0 if n_lines > 200: rep.findings.append( Finding( rep.domain, rep.name, rep.path, "DOCS", "low", "claude-md-bloat", f"CLAUDE.md is {n_lines} lines — may be a README clone " "(role split: CLAUDE.md = concise agent guide)", "trim to a gotcha-focused agent guide; move onboarding " "prose to README", ) ) def check_stray_dir(rep: RepoReport) -> None: if STRAY_DIR_RE.search(rep.name): rep.findings.append( Finding( rep.domain, rep.name, rep.path, "CLEANUP", "low", "stray-dir", "looks like a backup/broken scratch directory", "archive or delete once the live copy is confirmed good", ) ) CHECKS = [ check_stray_dir, check_git_and_ownership, check_branch_state, check_env_perms, check_compose, check_uv_stub, check_stale_review_docs, check_herbygitea_residue, check_docs, ] # --------------------------------------------------------------------------- # # Orchestration # --------------------------------------------------------------------------- # def discover_repos(domains: dict[str, dict[str, list[str]]]) -> list[RepoReport]: repos: list[RepoReport] = [] for domain, spec in domains.items(): for root in spec.get("scan", []): root_p = Path(root) if not root_p.is_dir(): continue for child in sorted(root_p.iterdir()): if not child.is_dir(): continue if child.name in SKIP_NAMES or child.name.startswith("."): continue repos.append(RepoReport(domain, child.name, str(child))) for repo_path in spec.get("repos", []): p = Path(repo_path) if p.is_dir(): repos.append(RepoReport(domain, p.name, str(p))) return repos def audit(domains: dict[str, dict[str, list[str]]]) -> list[RepoReport]: reports = discover_repos(domains) for rep in reports: for check in CHECKS: try: check(rep) except Exception as exc: # noqa: BLE001 — never let one repo abort the sweep rep.findings.append( Finding( rep.domain, rep.name, rep.path, "DOCS", "info", f"check-error:{check.__name__}", f"check {check.__name__} raised: {exc}", "audit.py bug — report it", ) ) return reports # --------------------------------------------------------------------------- # # Reporting # --------------------------------------------------------------------------- # def all_findings(reports: list[RepoReport]) -> list[Finding]: out: list[Finding] = [] for r in reports: out.extend(r.findings) out.sort( key=lambda f: ( SEVERITY_ORDER.get(f.severity, 9), f.category, f.domain, f.repo, ) ) return out def summarize(findings: list[Finding]) -> dict[str, dict[str, int]]: by_sev: dict[str, int] = {} by_cat: dict[str, int] = {} for f in findings: by_sev[f.severity] = by_sev.get(f.severity, 0) + 1 by_cat[f.category] = by_cat.get(f.category, 0) + 1 return {"by_severity": by_sev, "by_category": by_cat} def render_text(reports: list[RepoReport], findings: list[Finding]) -> str: lines: list[str] = [] lines.append("repo-hygiene-audit — findings") lines.append(f"host: {socket.gethostname()} scanned: {len(reports)} repos") summary = summarize(findings) sev = summary["by_severity"] lines.append( "severity: " + ", ".join(f"{k}={sev.get(k, 0)}" for k in ("high", "medium", "low", "info")) + f" total={len(findings)}" ) lines.append("=" * 70) # inventory line per repo (so clean repos are visible too) lines.append("\nINVENTORY") for r in sorted(reports, key=lambda x: (x.domain, x.name)): flag = "" if r.is_git else " [NON-GIT]" n = len(r.findings) own = f"{r.owner}:{r.group}" gown = f" git={r.git_owner}" if r.git_owner and r.git_owner != r.owner else "" lines.append( f" {r.domain:<8} {r.name:<32} {own:<22} " f"branch={r.branch or '-':<14}{gown} findings={n}{flag}" ) if not findings: lines.append("\nNo findings. Trees are clean. ") return "\n".join(lines) for cat in ("SECURITY", "AUDITABILITY", "CLEANUP", "GIT_STATE", "DOCS"): cat_f = [f for f in findings if f.category == cat] if not cat_f: continue lines.append(f"\n{cat} ({CATEGORY_PHASE.get(cat, '')}) [{len(cat_f)}]") lines.append("-" * 70) for f in cat_f: lines.append(f" [{f.severity:<6}] {f.domain}/{f.repo}: {f.detail}") if f.hint: lines.append(f" -> {f.hint}") return "\n".join(lines) def render_markdown(reports: list[RepoReport], findings: list[Finding]) -> str: now = dt.datetime.now().strftime("%Y-%m-%d %H:%M") out: list[str] = [] out.append("# Repo-hygiene audit findings") out.append("") out.append(f"_Generated {now} on {socket.gethostname()} — " f"{len(reports)} repos scanned._") out.append("") summary = summarize(findings) sev = summary["by_severity"] out.append( "**Severity:** " + ", ".join(f"{k} {sev.get(k, 0)}" for k in ("high", "medium", "low", "info")) + f" (total {len(findings)})" ) out.append("") out.append("Findings are grouped by the remediation-plan phase they map to, " "as `- [ ]` items so they paste straight into the project plan.") out.append("") for cat in ("SECURITY", "AUDITABILITY", "CLEANUP", "GIT_STATE", "DOCS"): cat_f = [f for f in findings if f.category == cat] if not cat_f: continue out.append(f"## {CATEGORY_PHASE.get(cat, cat)}") out.append("") for f in cat_f: hint = f" — _{f.hint}_" if f.hint else "" out.append( f"- [ ] `{f.domain}/{f.repo}` ({f.severity}): {f.detail}{hint}" ) out.append("") out.append("## Inventory") out.append("") out.append("| Domain | Repo | Owner:Group | Git | Branch | Findings |") out.append("|---|---|---|---|---|---|") for r in sorted(reports, key=lambda x: (x.domain, x.name)): out.append( f"| {r.domain} | {r.name} | {r.owner}:{r.group} | " f"{'yes' if r.is_git else 'NO'} | {r.branch or '-'} | " f"{len(r.findings)} |" ) out.append("") return "\n".join(out) def render_json(reports: list[RepoReport], findings: list[Finding]) -> str: payload = { "generated": dt.datetime.now().isoformat(timespec="seconds"), "host": socket.gethostname(), "repos_scanned": len(reports), "summary": summarize(findings), "inventory": [ { "domain": r.domain, "repo": r.name, "path": r.path, "is_git": r.is_git, "owner": r.owner, "git_owner": r.git_owner, "group": r.group, "branch": r.branch, "finding_count": len(r.findings), } for r in reports ], "findings": [asdict(f) | {"key": f.key} for f in findings], } return json.dumps(payload, indent=2) # --------------------------------------------------------------------------- # # CLI # --------------------------------------------------------------------------- # def load_domains(args: argparse.Namespace) -> dict[str, dict[str, list[str]]]: if args.config: return json.loads(Path(args.config).read_text()) if args.scan or args.repo: domains: dict[str, dict[str, list[str]]] = {} for item in args.scan or []: name, _, path = item.partition("=") domains.setdefault(name or "custom", {}).setdefault("scan", []).append( path or name ) for item in args.repo or []: name, _, path = item.partition("=") domains.setdefault(name or "custom", {}).setdefault("repos", []).append( path or name ) return domains return DEFAULT_DOMAINS def main(argv: list[str] | None = None) -> int: ap = argparse.ArgumentParser( description="Read-only repo hygiene audit across project domains." ) ap.add_argument( "--format", choices=("text", "json", "md"), default="text", help="output format (default: text)", ) ap.add_argument( "--output", "-o", help="write report to this path (default: stdout)" ) ap.add_argument( "--config", help="JSON file mapping domain -> {scan:[...], repos:[...]}", ) ap.add_argument( "--scan", action="append", metavar="NAME=PATH", help="scan immediate children of PATH as repos (repeatable)", ) ap.add_argument( "--repo", action="append", metavar="NAME=PATH", help="treat PATH itself as a repo (repeatable)", ) ap.add_argument( "--only", action="append", metavar="CATEGORY", help="only emit these categories (SECURITY/AUDITABILITY/CLEANUP/" "GIT_STATE/DOCS, repeatable)", ) ap.add_argument( "--fail-on", choices=("high", "medium", "low", "info"), default=None, help="exit non-zero if any finding at/above this severity exists", ) args = ap.parse_args(argv) domains = load_domains(args) reports = audit(domains) findings = all_findings(reports) if args.only: wanted = {c.upper() for c in args.only} findings = [f for f in findings if f.category in wanted] renderers = {"text": render_text, "json": render_json, "md": render_markdown} out = renderers[args.format](reports, findings) if args.output: Path(args.output).write_text(out + "\n") print(f"wrote {args.format} report -> {args.output} " f"({len(findings)} findings)", file=sys.stderr) else: print(out) if args.fail_on: threshold = SEVERITY_ORDER[args.fail_on] if any(SEVERITY_ORDER.get(f.severity, 9) <= threshold for f in findings): return 1 return 0 if __name__ == "__main__": raise SystemExit(main())