120 lines
3.4 KiB
Python
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()
|
|
|