Files
Deploy-Laboratory/ansible/tools/check_docs_no_parent_links.py
2026-03-29 09:08:01 +08:00

120 lines
3.4 KiB
Python

#!/usr/bin/env python3
"""Offline gate: forbid '../' in docs/*.md (R3).
Default: any '../' occurrence in docs markdown fails.
Exception: allowlist entries in scripts/offline-check-whitelist.json with expiry.
Expired allowlist entries fail the gate.
"""
from __future__ import annotations
import json
import sys
from dataclasses import dataclass
from datetime import date
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent.parent
DOCS_DIR = ROOT / "docs"
ALLOWLIST_PATH = ROOT / "scripts" / "offline-check-whitelist.json"
@dataclass(frozen=True)
class AllowItem:
file: str
rule: str
reason: str
expires: str
def _load_allowlist() -> list[AllowItem]:
if not ALLOWLIST_PATH.exists():
return []
try:
raw = json.loads(ALLOWLIST_PATH.read_text(encoding="utf-8"))
except Exception as e: # noqa: BLE001
raise RuntimeError(f"allowlist json parse error: {ALLOWLIST_PATH}: {e}") from e
if not isinstance(raw, list):
raise RuntimeError("allowlist must be a JSON list")
items: list[AllowItem] = []
for i, obj in enumerate(raw):
if not isinstance(obj, dict):
raise RuntimeError(f"allowlist item {i} must be object")
items.append(
AllowItem(
file=str(obj.get("file", "")),
rule=str(obj.get("rule", "")),
reason=str(obj.get("reason", "")),
expires=str(obj.get("expires", "")),
)
)
return items
def _parse_date(s: str) -> date:
try:
return date.fromisoformat(s)
except Exception as e: # noqa: BLE001
raise RuntimeError(f"invalid date (expected YYYY-MM-DD): {s}") from e
def main() -> None:
if not DOCS_DIR.is_dir():
print("ERR: docs 目录缺失", file=sys.stderr)
sys.exit(2)
allow_items = _load_allowlist()
allow_by_file: dict[str, list[AllowItem]] = {}
for it in allow_items:
if not it.file or not it.rule or not it.reason or not it.expires:
print(f"ERR: allowlist item missing fields: {it}", file=sys.stderr)
sys.exit(2)
allow_by_file.setdefault(it.file, []).append(it)
today = date.today()
expired: list[str] = []
for it in allow_items:
if _parse_date(it.expires) < today:
expired.append(f"{it.file} ({it.rule}) expired {it.expires}: {it.reason}")
if expired:
print("ERR: offline-check allowlist expired:", file=sys.stderr)
for line in expired:
print(f" - {line}", file=sys.stderr)
sys.exit(2)
violations: list[str] = []
for md in sorted(DOCS_DIR.glob("*.md")):
rel = md.relative_to(ROOT).as_posix()
content = md.read_text(encoding="utf-8", errors="ignore")
if "../" not in content:
continue
allowed = False
for it in allow_by_file.get(rel, []):
if it.rule == "docs_no_parent_links":
allowed = True
break
if not allowed:
violations.append(rel)
if violations:
print("ERR: docs contains '../' (R3 forbids parent links):", file=sys.stderr)
for rel in violations:
print(f" - {rel}", file=sys.stderr)
print(
f"HINT: add temporary allowlist item in {ALLOWLIST_PATH} with expires+reason.",
file=sys.stderr,
)
sys.exit(2)
print("[OK] docs R3 parent-link check passed (no '../' found)")
if __name__ == "__main__":
main()