#!/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())