File size: 7,257 Bytes
216004d | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 | from __future__ import annotations
# llm-wiki-version: 2.0.0
# runtime: ci-safe (structural only)
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
WIKI_ROOT = ROOT / "docs" / "wiki"
REQUIRED_FILES = [
"README.md",
"SCHEMA.md",
"index.md",
"log.md",
"project-overview.md",
"current-status.md",
"sources-and-data.md",
"github-and-raw-strategy.md",
]
LOG_HEADER_RE = re.compile(r"^## \[\d{4}-\d{2}-\d{2}\] .+ \| .+$")
FRONTMATTER_RE = re.compile(r"^---\n(.*?)\n---", re.DOTALL)
FRONTMATTER_EXEMPT = {"index.md", "log.md", "README.md", "SCHEMA.md"}
REQUIRED_FRONTMATTER_KEYS = {"title", "source", "created"}
# v2 升级:log.md 支持表格格式("table")或 H2 日期头("header"),默认 "table"
# 表格格式 = 第一行以 "| " 开头(markdown 表格)
LOG_FORMAT = "table" # "table" | "header"
def is_log_table_format(text: str) -> bool:
"""log.md 是否用 markdown 表格(≥3 行以 | 开头)。"""
table_lines = [ln for ln in text.splitlines() if ln.startswith("|")]
return len(table_lines) >= 3
# Don't validate links inside fenced code (```), indented code, or inline code.
FENCE_RE = re.compile(r"^(\s*)(```|~~~)")
INLINE_CODE_RE = re.compile(r"`[^`\n]*`")
LINK_OPEN_RE = re.compile(r"!?\[(?:[^\[\]\\]|\\.)*\]\(")
def strip_inline_code(line: str) -> str:
return INLINE_CODE_RE.sub("", line)
def iter_link_targets(text: str) -> list[str]:
"""Return every (link target) found outside fenced/inline code.
Properly handles balanced parens inside the URL portion (rare but legal).
"""
out: list[str] = []
in_fence = False
fence_marker = ""
for raw_line in text.splitlines():
m = FENCE_RE.match(raw_line)
if m:
marker = m.group(2)
if not in_fence:
in_fence = True
fence_marker = marker
elif marker == fence_marker:
in_fence = False
fence_marker = ""
continue
if in_fence:
continue
line = strip_inline_code(raw_line)
pos = 0
while True:
m = LINK_OPEN_RE.search(line, pos)
if not m:
break
start = m.end()
depth = 1
i = start
while i < len(line) and depth > 0:
ch = line[i]
if ch == "\\" and i + 1 < len(line):
i += 2
continue
if ch == "(":
depth += 1
elif ch == ")":
depth -= 1
if depth == 0:
break
i += 1
if depth == 0:
target = line[start:i].strip()
if target.startswith("<") and target.endswith(">"):
target = target[1:-1].strip()
title_match = re.search(r'\s+["\'(].*["\')]$', target)
if title_match:
target = target[: title_match.start()].strip()
out.append(target)
pos = i + 1
else:
pos = len(line)
return out
def check_frontmatter(path: Path, text: str) -> list[str]:
errs: list[str] = []
if path.name in FRONTMATTER_EXEMPT:
return errs
m = FRONTMATTER_RE.match(text)
if not m:
errs.append(f"missing frontmatter: {path.relative_to(ROOT)}")
return errs
body = m.group(1)
found_keys: set[str] = set()
for line in body.splitlines():
if ":" in line:
key = line.split(":", 1)[0].strip()
found_keys.add(key)
missing = REQUIRED_FRONTMATTER_KEYS - found_keys
if missing:
errs.append(f"missing frontmatter keys in {path.relative_to(ROOT)}: {', '.join(sorted(missing))}")
return errs
def resolve_link(source_file: Path, target: str) -> Path | None:
if target.startswith(("http://", "https://", "mailto:", "tel:", "ftp://")):
return None
if target.startswith("#"):
return None
target = target.split("#", 1)[0].split("?", 1)[0]
if not target:
return None
return (source_file.parent / target).resolve()
def index_referenced_pages(index_path: Path) -> set[str]:
"""Set of wiki-relative posix paths that index.md actually links to.
Uses real link parsing instead of substring matching, so a page named
`policy.md` doesn't accidentally pass because index.md mentions
`pricing-policy.md`.
"""
if not index_path.exists():
return set()
text = index_path.read_text(encoding="utf-8")
refs: set[str] = set()
for raw_target in iter_link_targets(text):
resolved = resolve_link(index_path, raw_target)
if resolved is None:
continue
try:
rel = resolved.relative_to(WIKI_ROOT.resolve()).as_posix()
except ValueError:
continue
refs.add(rel)
return refs
def main() -> int:
errors: list[str] = []
if not WIKI_ROOT.exists():
print("wiki_check: docs/wiki does not exist")
return 1
for rel in REQUIRED_FILES:
if not (WIKI_ROOT / rel).exists():
errors.append(f"missing required file: docs/wiki/{rel}")
md_files = sorted(WIKI_ROOT.rglob("*.md"))
index_refs = index_referenced_pages(WIKI_ROOT / "index.md")
EXEMPT_FROM_INDEX = {"README.md", "SCHEMA.md", "log.md", "index.md"}
fm_ok = 0
for path in md_files:
text = path.read_text(encoding="utf-8")
fm_errors = check_frontmatter(path, text)
errors.extend(fm_errors)
if not fm_errors and path.name not in FRONTMATTER_EXEMPT:
fm_ok += 1
if path.name == "log.md":
# v2 升级:表格格式下跳过 H2 日期头校验
if LOG_FORMAT == "header":
for line in text.splitlines():
if line.startswith("## ") and not LOG_HEADER_RE.match(line):
errors.append(f"bad log header format in {path.relative_to(ROOT)}: {line}")
elif LOG_FORMAT == "table":
if not is_log_table_format(text):
errors.append(f"log.md not in table format (LOG_FORMAT=table) in {path.relative_to(ROOT)}")
rel = path.relative_to(WIKI_ROOT).as_posix()
if rel not in EXEMPT_FROM_INDEX and rel not in index_refs:
errors.append(f"index.md does not reference docs/wiki/{rel}")
for raw_target in iter_link_targets(text):
resolved = resolve_link(path, raw_target)
if resolved is None:
continue
if not resolved.exists():
errors.append(f"broken link in {path.relative_to(ROOT)} -> {raw_target}")
if errors:
print("wiki_check: FAILED")
for item in errors:
print(f"- {item}")
return 1
print("wiki_check: OK")
print(f"- markdown files: {len(md_files)}")
print(f"- required files: {len(REQUIRED_FILES)}")
print(f"- frontmatter valid: {fm_ok}")
print(f"- index.md links: {len(index_refs)}")
print(f"- wiki root: {WIKI_ROOT}")
return 0
if __name__ == "__main__":
sys.exit(main())
|