316 lines
12 KiB
Python
Executable File
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())
|