Files
pgr/scripts/audit_text_hygiene.py

137 lines
4.0 KiB
Python

#!/usr/bin/env python3
# file: scripts/audit_text_hygiene.py
# version: 1
"""Validate mechanical hygiene of selected governed text files."""
from __future__ import annotations
import argparse
import pathlib
import sys
_IGNORED_DIRECTORIES = frozenset({".git", ".idea", ".venv", "__pycache__", "dist", "node_modules", "target"})
_TEXT_SUFFIXES = frozenset(
{
".cfg",
".conf",
".css",
".gradle",
".html",
".ini",
".js",
".json",
".jsx",
".kts",
".md",
".prefs",
".properties",
".py",
".rs",
".scss",
".sh",
".toml",
".ts",
".tsx",
".txt",
".xml",
".yaml",
".yml",
}
)
_TEXT_NAMES = frozenset({".gitignore", "VERSION"})
def _is_governed_text(path: pathlib.Path) -> bool:
return path.name in _TEXT_NAMES or path.suffix.lower() in _TEXT_SUFFIXES
def _selected_files(raw_paths: list[str], root: pathlib.Path) -> tuple[list[pathlib.Path], list[str]]:
files: set[pathlib.Path] = set()
errors: list[str] = []
for raw_path in raw_paths:
path = pathlib.Path(raw_path)
if path.is_dir() or path.is_file():
if not path.resolve().is_relative_to(root):
errors.append(f"Text hygiene audit: path outside project root: {path}")
continue
if path.is_dir():
candidates = (
candidate
for candidate in path.rglob("*")
if candidate.is_file()
and _is_governed_text(candidate)
and not any(part in _IGNORED_DIRECTORIES for part in candidate.parts)
)
elif path.is_file() and _is_governed_text(path):
candidates = iter((path,))
else:
errors.append(f"Text hygiene audit: unsupported or missing path: {path}")
continue
for candidate in candidates:
canonical = candidate.resolve()
if not canonical.is_relative_to(root):
errors.append(f"Text hygiene audit: path outside project root: {candidate}")
else:
files.add(canonical)
return sorted(files), errors
def _audit_file(path: pathlib.Path, root: pathlib.Path) -> list[str]:
relative = path.relative_to(root).as_posix()
data = path.read_bytes()
errors: list[str] = []
if b"\r" in data:
errors.append(f"{relative}: CR byte found; governed text must use LF line endings")
if not data.endswith(b"\n"):
errors.append(f"{relative}: EOF: missing final LF")
elif data.endswith(b"\n\n"):
errors.append(f"{relative}: EOF: extra blank line; exactly one final LF is required")
for line_number, line in enumerate(data.split(b"\n")[:-1], start=1):
if line.endswith((b" ", b"\t")):
errors.append(f"{relative}:{line_number}: trailing whitespace")
return errors
def main() -> int:
"""Run the read-only text hygiene audit from the project root."""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("paths", nargs="+", help="Governed text files or directories to audit")
args = parser.parse_args()
root = pathlib.Path.cwd().resolve()
files, selection_errors = _selected_files(args.paths, root)
if selection_errors or not files:
for error in selection_errors or ["Text hygiene audit: no governed text files selected"]:
print(error, file=sys.stderr)
return 2
errors: list[str] = []
for path in files:
try:
errors.extend(_audit_file(path, root))
except OSError as error:
print(f"Text hygiene audit: cannot read {path}: {error}", file=sys.stderr)
return 2
for error in errors:
print(error, file=sys.stderr)
if errors:
print(f"Text hygiene audit: {len(errors)} error(s) across {len(files)} file(s)", file=sys.stderr)
return 1
print(f"Text hygiene audit: clean ({len(files)} file(s))")
return 0
if __name__ == "__main__":
raise SystemExit(main())