97 lines
3.3 KiB
Python
97 lines
3.3 KiB
Python
#!/usr/bin/env python3
|
|
# file: scripts/audit_markdown_headers.py
|
|
# version: 1
|
|
|
|
"""Validate path and positive revision in selected Markdown file headers."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import pathlib
|
|
import re
|
|
import sys
|
|
|
|
_IGNORED_DIRECTORIES = frozenset({".git", ".idea", ".venv", "__pycache__", "dist", "node_modules", "target"})
|
|
_VERSION = re.compile(r"<!-- version: [1-9][0-9]* -->")
|
|
|
|
|
|
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"Markdown header audit: path outside project root: {path}")
|
|
continue
|
|
if path.is_dir():
|
|
candidates = (
|
|
candidate
|
|
for candidate in path.rglob("*.md")
|
|
if candidate.is_file() and not any(part in _IGNORED_DIRECTORIES for part in candidate.parts)
|
|
)
|
|
elif path.is_file() and path.suffix.lower() == ".md":
|
|
candidates = iter((path,))
|
|
else:
|
|
errors.append(f"Markdown header audit: unsupported or missing path: {path}")
|
|
continue
|
|
|
|
for candidate in candidates:
|
|
canonical = candidate.resolve()
|
|
if not canonical.is_relative_to(root):
|
|
errors.append(f"Markdown header 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()
|
|
lines = path.read_text(encoding="utf-8").splitlines()
|
|
errors: list[str] = []
|
|
expected_file = f"<!-- file: {relative} -->"
|
|
|
|
if not lines or lines[0] != expected_file:
|
|
errors.append(f"{relative}:1: expected `{expected_file}`")
|
|
if len(lines) < 2 or _VERSION.fullmatch(lines[1]) is None:
|
|
errors.append(f"{relative}:2: expected positive `<!-- version: N -->`")
|
|
return errors
|
|
|
|
|
|
def main() -> int:
|
|
"""Run the read-only Markdown header audit from the project root."""
|
|
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("paths", nargs="+", help="Markdown 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 ["Markdown header audit: no Markdown 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, UnicodeError) as error:
|
|
print(f"Markdown header audit: cannot read {path}: {error}", file=sys.stderr)
|
|
return 2
|
|
|
|
for error in errors:
|
|
print(error, file=sys.stderr)
|
|
if errors:
|
|
print(f"Markdown header audit: {len(errors)} error(s) across {len(files)} file(s)", file=sys.stderr)
|
|
return 1
|
|
|
|
print(f"Markdown header audit: clean ({len(files)} file(s))")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|