Code / tools/check_headers.py

tools/check_headers.py 144 lines
#!/usr/bin/env python3
# =============================================================================
#  Project   : localvm-research
#  File      : tools/check_headers.py
#  Purpose   : CI-style enforcement of the mandatory author header (CLAUDE.md §0.1)
#  Author    : Simon-Pierre Boucher
#  Contact   : contact@spboucher.ai
#  Created   : 2026-08-11
#  Modified  : 2026-08-11
#  Platform  : macOS / Apple Silicon (arm64)
#  License   : All rights reserved (research code)
# =============================================================================
"""Fail (exit 1) if any tracked source file lacks a conforming author header.

Usage:
    python3 tools/check_headers.py            # check all git-tracked files
    python3 tools/check_headers.py FILE...    # check specific files

Rules enforced (see CLAUDE.md §0.1):
  * Comment-style sources (.py .sh .zsh .yaml .yml .toml .cff Makefile
    CMakeLists.txt) must contain the '#'-style header block near the top.
  * C-family sources (.c .cpp .h .hpp .metal .swift .m .mm) must contain the
    '//'-style header block near the top.
  * Markdown documents must begin with YAML front matter declaring
    project/author/contact.
  * A shebang line may precede the header.

Exemptions: generated results under results/, LICENSE, .gitignore is checked
(it supports comments), CLAUDE.md (the charter predates the convention and is
the specification itself).
"""

from __future__ import annotations

import subprocess
import sys
from pathlib import Path

REPO_ROOT = Path(__file__).resolve().parent.parent

REQUIRED_FIELDS = ("Project", "File", "Purpose", "Author", "Contact",
                   "Created", "Modified", "Platform", "License")
AUTHOR = "Simon-Pierre Boucher"
CONTACT = "contact@spboucher.ai"

HASH_EXTS = {".py", ".sh", ".zsh", ".bash", ".yaml", ".yml", ".toml", ".cff"}
SLASH_EXTS = {".c", ".cc", ".cpp", ".h", ".hpp", ".metal", ".swift", ".m", ".mm"}
HASH_NAMES = {"Makefile", "CMakeLists.txt", ".gitignore"}

EXEMPT_NAMES = {"LICENSE", "CLAUDE.md", "MEMORY.md"}
EXEMPT_DIRS = {"results"}
# How many leading lines to scan for the header block (allows shebang etc.).
SCAN_LINES = 20


def tracked_files() -> list[Path]:
    out = subprocess.run(
        ["git", "ls-files"], cwd=REPO_ROOT, capture_output=True, text=True, check=True
    ).stdout
    return [REPO_ROOT / line for line in out.splitlines() if line.strip()]


def is_exempt(path: Path) -> bool:
    rel = path.relative_to(REPO_ROOT)
    if rel.name in EXEMPT_NAMES:
        return True
    return bool(rel.parts and rel.parts[0] in EXEMPT_DIRS)


def check_comment_header(lines: list[str], prefix: str) -> list[str]:
    """Check for a comment-style header with all required fields near the top."""
    head = "\n".join(lines[:SCAN_LINES])
    errors = []
    for field in REQUIRED_FIELDS:
        if f"{prefix}  {field}" not in head and f"{prefix} {field}" not in head:
            errors.append(f"missing header field: {field}")
    if AUTHOR not in head:
        errors.append(f"missing author name '{AUTHOR}'")
    if CONTACT not in head:
        errors.append(f"missing contact '{CONTACT}'")
    return errors


def check_markdown_front_matter(lines: list[str]) -> list[str]:
    if not lines or lines[0].strip() != "---":
        return ["markdown file must start with YAML front matter (---)"]
    errors = []
    try:
        end = next(i for i in range(1, min(len(lines), SCAN_LINES)) if lines[i].strip() == "---")
    except StopIteration:
        return ["unterminated YAML front matter"]
    block = "\n".join(lines[1:end])
    for key in ("project: localvm-research", f"author: {AUTHOR}", f"contact: {CONTACT}"):
        if key not in block:
            errors.append(f"front matter missing '{key}'")
    return errors


def check_file(path: Path) -> list[str]:
    try:
        text = path.read_text(encoding="utf-8", errors="replace")
    except OSError as exc:
        return [f"unreadable: {exc}"]
    lines = text.splitlines()
    if lines and lines[0].startswith("#!"):
        lines = lines[1:]

    name, ext = path.name, path.suffix
    if ext in HASH_EXTS or name in HASH_NAMES:
        return check_comment_header(lines, "#")
    if ext in SLASH_EXTS:
        return check_comment_header(lines, "//")
    if ext == ".md":
        return check_markdown_front_matter(lines)
    return []  # other file types are not subject to the header rule


def main(argv: list[str]) -> int:
    paths = [Path(p).resolve() for p in argv] if argv else tracked_files()
    failures: dict[str, list[str]] = {}
    checked = 0
    for path in paths:
        if not path.is_file() or is_exempt(path):
            continue
        errors = check_file(path)
        if path.suffix in HASH_EXTS | SLASH_EXTS | {".md"} or path.name in HASH_NAMES:
            checked += 1
        if errors:
            failures[str(path.relative_to(REPO_ROOT))] = errors

    if failures:
        print(f"HEADER CHECK FAILED — {len(failures)} non-conforming file(s):\n")
        for rel, errors in sorted(failures.items()):
            print(f"  {rel}")
            for err in errors:
                print(f"    - {err}")
        return 1
    print(f"Header check passed ({checked} files checked).")
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))