#!/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()