wiki-vault/wiki-context are an MCP-owned, managed surface — a second scanner risks interfering with how the vault MCP maintains its context. Remove the 'root' domain from DEFAULT_DOMAINS so a plain run no longer touches the vault; the --scan/--repo escape hatch still allows manual one-offs. Updated SKILL.md, check-catalog, findings-format, README, CLAUDE.md with the rationale. Default run: 3 domains, 37 repos, 54 findings (was 61); clears the vault-only false positives (herbygitea doc-mention, wiki-context uv-init stub).
914 lines
31 KiB
Python
914 lines
31 KiB
Python
#!/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} <repo> (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 <path>' — 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())
|