Blacksite/scripts/codex/docs_audit.py

316 lines
12 KiB
Python
Executable File

#!/usr/bin/env python3
"""Validate Blacksite documentation authority coverage and lifecycle banners."""
from __future__ import annotations
import argparse
import fnmatch
import json
import re
import subprocess
import sys
import tomllib
from collections import Counter
from dataclasses import dataclass
from pathlib import Path
from typing import Any
AUTHORITY_PATH = Path("docs/authority.toml")
BANNER_MARKERS = {
"active-plan": "**active plan",
"evidence": "**evidence record",
"historical": "**historical",
"superseded": "**superseded",
}
LINK_RE = re.compile(r"\[[^\]]+\]\(([^)]+)\)")
@dataclass(frozen=True)
class Rule:
id: str
classification: str
priority: int
path: str | None = None
prefix: str | None = None
role: str | None = None
replacement: str | None = None
topics: tuple[str, ...] = ()
def matches(self, candidate: str) -> bool:
if self.path is not None:
return candidate == self.path
if self.prefix is not None:
return candidate.startswith(self.prefix)
return False
def repository_root() -> Path:
current = Path(__file__).resolve()
for parent in current.parents:
if (parent / AUTHORITY_PATH).is_file():
return parent
raise RuntimeError(f"cannot locate repository root containing {AUTHORITY_PATH}")
def load_authority(root: Path) -> tuple[dict[str, Any], list[Rule]]:
raw = tomllib.loads((root / AUTHORITY_PATH).read_text(encoding="utf-8"))
rules: list[Rule] = []
for item in raw.get("rules", []):
selectors = [name for name in ("path", "prefix") if item.get(name) is not None]
if len(selectors) != 1:
raise ValueError(f"rule {item.get('id', '<unknown>')} must set exactly one path or prefix")
rules.append(
Rule(
id=item["id"],
classification=item["classification"],
priority=int(item.get("priority", 0)),
path=item.get("path"),
prefix=item.get("prefix"),
role=item.get("role"),
replacement=item.get("replacement"),
topics=tuple(item.get("topics", [])),
)
)
return raw, rules
def discover_documents(root: Path, authority: dict[str, Any]) -> list[str]:
discovery = authority.get("discovery", {})
found: set[str] = set()
for relative in discovery.get("paths", []):
path = root / relative
if path.is_file() and path.suffix == ".md":
found.add(path.relative_to(root).as_posix())
for relative in discovery.get("trees", []):
tree = root / relative
if tree.is_dir():
found.update(path.relative_to(root).as_posix() for path in tree.rglob("*.md"))
return sorted(found)
def resolve_rule(path: str, rules: list[Rule]) -> tuple[Rule | None, str | None]:
matches = [rule for rule in rules if rule.matches(path)]
if not matches:
return None, None
highest = max(rule.priority for rule in matches)
winners = [rule for rule in matches if rule.priority == highest]
classifications = {rule.classification for rule in winners}
if len(classifications) != 1:
names = ", ".join(rule.id for rule in winners)
return None, f"equal-priority rules disagree: {names}"
winners.sort(key=lambda rule: (rule.path is not None, len(rule.path or rule.prefix or "")), reverse=True)
return winners[0], None
def normalized_link_target(root: Path, document: Path, raw_target: str) -> str | None:
target = raw_target.split("#", 1)[0].strip().strip("<>")
if not target or "://" in target or target.startswith("mailto:"):
return None
resolved = (document.parent / target).resolve()
try:
return resolved.relative_to(root.resolve()).as_posix()
except ValueError:
return None
def path_matches(path: str, pattern: str) -> bool:
if pattern.endswith("/**"):
return path.startswith(pattern[:-2])
return fnmatch.fnmatchcase(path, pattern)
def validate_links(root: Path, relative: str) -> list[str]:
path = root / relative
text = path.read_text(encoding="utf-8")
errors: list[str] = []
for raw_target in LINK_RE.findall(text):
target = raw_target.split("#", 1)[0].strip().strip("<>")
if not target or "://" in target or target.startswith(("mailto:", "#")):
continue
normalized = normalized_link_target(root, path, raw_target)
if normalized is None:
errors.append(f"link escapes the repository: {raw_target}")
continue
if not (root / normalized).exists():
errors.append(f"broken relative link: {raw_target}")
return errors
def changed_documents(root: Path) -> set[str]:
changed: set[str] = set()
commands = [
["git", "diff", "--name-only", "--relative", "HEAD"],
["git", "ls-files", "--others", "--exclude-standard"],
]
for command in commands:
result = subprocess.run(command, cwd=root, check=False, capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(f"failed to query changed files: {' '.join(command)}")
changed.update(line.strip() for line in result.stdout.splitlines() if line.strip())
return changed
def validate_banner(root: Path, relative: str, rule: Rule) -> list[str]:
if rule.classification == "current":
return []
path = root / relative
text = path.read_text(encoding="utf-8")
head = "\n".join(text.splitlines()[:180])
lowered = head.lower()
marker = BANNER_MARKERS[rule.classification]
if marker not in lowered:
# ADR 0019 predates Markdown heading style but has an explicit first-line status.
if not (rule.classification == "superseded" and lowered.startswith("status: superseded")):
return [f"missing {rule.classification} banner near the start of the document"]
if rule.classification not in {"evidence", "historical", "superseded"}:
return []
links = {
normalized
for target in LINK_RE.findall(head)
if (normalized := normalized_link_target(root, path, target)) is not None
}
if not links:
return ["lifecycle banner must link to current canonical guidance"]
if rule.replacement and rule.replacement not in links:
return [f"lifecycle banner must link to configured replacement {rule.replacement}"]
return []
def audit(root: Path, *, changed_only: bool = False, topic: str | None = None) -> dict[str, Any]:
authority, rules = load_authority(root)
allowed = set(authority.get("classifications", []))
allowed_roles = set(authority.get("roles", []))
errors: list[str] = []
warnings: list[str] = []
if authority.get("version") != 1:
errors.append("docs/authority.toml: unsupported or missing version")
if allowed != {"current", "active-plan", "evidence", "historical", "superseded"}:
errors.append("docs/authority.toml: classifications must be the five workflow classes")
for rule in rules:
if rule.classification not in allowed:
errors.append(f"rule {rule.id}: unknown classification {rule.classification}")
if not rule.role or rule.role not in allowed_roles:
errors.append(f"rule {rule.id}: unknown or missing role {rule.role}")
if rule.path and not (root / rule.path).is_file():
errors.append(f"rule {rule.id}: exact path does not exist: {rule.path}")
if rule.replacement and not (root / rule.replacement).is_file():
errors.append(f"rule {rule.id}: replacement does not exist: {rule.replacement}")
counts: Counter[str] = Counter()
documents: list[dict[str, str]] = []
resolved: list[tuple[str, Rule]] = []
for relative in discover_documents(root, authority):
rule, conflict = resolve_rule(relative, rules)
if conflict:
errors.append(f"{relative}: {conflict}")
continue
if rule is None:
errors.append(f"{relative}: unclassified")
continue
resolved.append((relative, rule))
counts[rule.classification] += 1
documents.append(
{
"path": relative,
"classification": rule.classification,
"rule": rule.id,
"role": rule.role or "",
"topics": ",".join(rule.topics),
}
)
canonical_owners: dict[str, list[str]] = {}
for relative, rule in resolved:
if rule.role == "canonical" and rule.classification == "current":
for owned_topic in rule.topics:
canonical_owners.setdefault(owned_topic, []).append(relative)
for owned_topic, owners in sorted(canonical_owners.items()):
if len(owners) > 1:
errors.append(f"topic {owned_topic}: multiple canonical owners: {', '.join(owners)}")
selected = resolved
if changed_only:
changed = changed_documents(root)
if AUTHORITY_PATH.as_posix() not in changed:
selected = [(path, rule) for path, rule in selected if path in changed]
if topic:
selected = [(path, rule) for path, rule in selected if topic in rule.topics]
if not selected:
errors.append(f"topic {topic}: no classified documents")
stale_terms = authority.get("stale_terms", [])
for relative, rule in selected:
for error in validate_banner(root, relative, rule):
errors.append(f"{relative}: {error}")
for error in validate_links(root, relative):
errors.append(f"{relative}: {error}")
if rule.classification != "current":
continue
text = (root / relative).read_text(encoding="utf-8")
lowered = text.lower()
for stale in stale_terms:
if any(path_matches(relative, allowed_path) for allowed_path in stale.get("allowed_paths", [])):
continue
pattern = stale["pattern"]
if pattern.lower() in lowered:
errors.append(
f"{relative}: stale term {pattern!r}; use {stale.get('replacement', 'current terminology')}"
)
if rule.role == "canonical":
if re.search(r"(?im)^\s*- \[ \]", text):
warnings.append(f"{relative}: unchecked checklist in canonical documentation; review status semantics")
if re.search(r"(?i)\bnot implemented\b|\((?:still\s+)?planned\)", text):
warnings.append(f"{relative}: possible future-status language in canonical documentation; review semantically")
return {
"ok": not errors,
"authority": AUTHORITY_PATH.as_posix(),
"documents": documents,
"counts": dict(sorted(counts.items())),
"errors": errors,
"warnings": warnings,
"selected": len(selected),
}
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--json", action="store_true", help="emit the complete machine-readable report")
parser.add_argument("--list", action="store_true", help="list every classified document")
parser.add_argument("--changed", action="store_true", help="check lifecycle, links, and terms only in changed documents")
parser.add_argument("--topic", help="check lifecycle, links, and terms only for one configured topic")
args = parser.parse_args()
try:
result = audit(repository_root(), changed_only=args.changed, topic=args.topic)
except (OSError, RuntimeError, ValueError, tomllib.TOMLDecodeError) as error:
print(f"FAIL docs-authority — {error}", file=sys.stderr)
return 1
if args.json:
print(json.dumps(result, indent=2, sort_keys=True))
else:
status = "PASS" if result["ok"] else "FAIL"
counts = ", ".join(f"{name}={count}" for name, count in result["counts"].items())
print(
f"{status} docs-authority — {len(result['documents'])} classified, "
f"{result['selected']} checked — {counts}"
)
if args.list:
for document in result["documents"]:
print(f"{document['classification']:11} {document['path']} ({document['rule']})")
for error in result["errors"]:
print(f"ERROR {error}", file=sys.stderr)
for warning in result["warnings"]:
print(f"WARN {warning}", file=sys.stderr)
return 0 if result["ok"] else 1
if __name__ == "__main__":
raise SystemExit(main())