♻️ refactor(test): consolidate repository checks
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Iterator, Optional, Sequence
|
||||
|
||||
|
||||
EXCLUDED_DIRECTORIES = {".git", "build", "dist", "node_modules", "tmp"}
|
||||
FENCE_PATTERN = re.compile(r"^\s*(`{3,}|~{3,})")
|
||||
INLINE_CODE_PATTERN = re.compile(r"`[^`]*`")
|
||||
INLINE_LINK_PATTERN = re.compile(r"!?\[[^]]*\]\(([^)]*)\)")
|
||||
REFERENCE_LINK_PATTERN = re.compile(
|
||||
r"^\s*\[[^]]+\]:\s*(?:<([^>]+)>|([^\s]+))"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MarkdownLink:
|
||||
line: int
|
||||
target: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BrokenLink:
|
||||
source: Path
|
||||
line: int
|
||||
target: str
|
||||
resolved_target: Path
|
||||
|
||||
|
||||
@dataclass
|
||||
class LinkCheckResult:
|
||||
markdown_files: int = 0
|
||||
total_links: int = 0
|
||||
valid_links: int = 0
|
||||
skipped_links: int = 0
|
||||
broken_links: list[BrokenLink] = field(default_factory=list)
|
||||
|
||||
|
||||
def extract_links(text: str) -> Iterator[MarkdownLink]:
|
||||
fence_character: Optional[str] = None
|
||||
|
||||
for line_number, raw_line in enumerate(text.splitlines(), start=1):
|
||||
fence_match = FENCE_PATTERN.match(raw_line)
|
||||
if fence_match:
|
||||
marker_character = fence_match.group(1)[0]
|
||||
if fence_character is None:
|
||||
fence_character = marker_character
|
||||
elif marker_character == fence_character:
|
||||
fence_character = None
|
||||
continue
|
||||
if fence_character is not None:
|
||||
continue
|
||||
|
||||
line = INLINE_CODE_PATTERN.sub("", raw_line)
|
||||
for match in INLINE_LINK_PATTERN.finditer(line):
|
||||
yield MarkdownLink(line_number, match.group(1).strip())
|
||||
|
||||
reference_match = REFERENCE_LINK_PATTERN.match(line)
|
||||
if reference_match:
|
||||
target = reference_match.group(1) or reference_match.group(2)
|
||||
yield MarkdownLink(line_number, target.strip())
|
||||
|
||||
|
||||
def iter_markdown_files(root: Path) -> Iterator[Path]:
|
||||
for path in sorted(root.rglob("*.md")):
|
||||
relative_path = path.relative_to(root)
|
||||
if path.name.endswith(".template.md"):
|
||||
continue
|
||||
if any(part in EXCLUDED_DIRECTORIES for part in relative_path.parts[:-1]):
|
||||
continue
|
||||
if path.is_file():
|
||||
yield path
|
||||
|
||||
|
||||
def _local_target(target: str) -> Optional[str]:
|
||||
target = target.strip()
|
||||
if target.startswith("<") and target.endswith(">"):
|
||||
target = target[1:-1].strip()
|
||||
|
||||
target = target.split("#", 1)[0]
|
||||
if not target:
|
||||
return None
|
||||
|
||||
lower_target = target.lower()
|
||||
if lower_target.startswith(("http://", "https://", "mailto:")):
|
||||
return None
|
||||
if target.startswith("/en/docs/"):
|
||||
return None
|
||||
return target
|
||||
|
||||
|
||||
def _resolve_target(root: Path, source: Path, target: str) -> Path:
|
||||
if target.startswith("/"):
|
||||
return (root / target.lstrip("/")).resolve()
|
||||
return (source.parent / target).resolve()
|
||||
|
||||
|
||||
def check_repository(root: Path) -> LinkCheckResult:
|
||||
root = root.resolve()
|
||||
markdown_files = list(iter_markdown_files(root))
|
||||
result = LinkCheckResult(markdown_files=len(markdown_files))
|
||||
|
||||
for source in markdown_files:
|
||||
text = source.read_text(encoding="utf-8")
|
||||
for link in extract_links(text):
|
||||
result.total_links += 1
|
||||
local_target = _local_target(link.target)
|
||||
if local_target is None:
|
||||
result.skipped_links += 1
|
||||
continue
|
||||
|
||||
resolved_target = _resolve_target(root, source, local_target)
|
||||
if resolved_target.exists():
|
||||
result.valid_links += 1
|
||||
continue
|
||||
|
||||
result.broken_links.append(
|
||||
BrokenLink(source, link.line, link.target, resolved_target)
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _display_path(path: Path, root: Path) -> str:
|
||||
try:
|
||||
return path.relative_to(root).as_posix()
|
||||
except ValueError:
|
||||
return str(path)
|
||||
|
||||
|
||||
def print_report(result: LinkCheckResult, root: Path) -> None:
|
||||
root = root.resolve()
|
||||
print(f"Playbook root: {root}")
|
||||
print(f"Markdown files: {result.markdown_files}")
|
||||
|
||||
for broken in result.broken_links:
|
||||
source = _display_path(broken.source, root)
|
||||
target = _display_path(broken.resolved_target, root)
|
||||
print(f"BROKEN: {source}:{broken.line}")
|
||||
print(f" Link: {broken.target}")
|
||||
print(f" Target: {target}")
|
||||
|
||||
print(f"Total links: {result.total_links}")
|
||||
print(f"Valid links: {result.valid_links}")
|
||||
print(f"Skipped links: {result.skipped_links}")
|
||||
print(f"Broken links: {len(result.broken_links)}")
|
||||
|
||||
|
||||
def parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Check local links in Markdown files.")
|
||||
parser.add_argument(
|
||||
"--root",
|
||||
type=Path,
|
||||
default=Path(__file__).resolve().parents[2],
|
||||
help="repository root to scan (default: inferred from this script)",
|
||||
)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def _configure_output() -> None:
|
||||
for stream in (sys.stdout, sys.stderr):
|
||||
reconfigure = getattr(stream, "reconfigure", None)
|
||||
if reconfigure is not None:
|
||||
reconfigure(errors="backslashreplace")
|
||||
|
||||
|
||||
def main(argv: Optional[Sequence[str]] = None) -> int:
|
||||
_configure_output()
|
||||
args = parse_args(argv)
|
||||
root = args.root.resolve()
|
||||
if not root.is_dir():
|
||||
print(f"ERROR: root directory does not exist: {root}")
|
||||
return 2
|
||||
|
||||
result = check_repository(root)
|
||||
print_report(result, root)
|
||||
if result.broken_links:
|
||||
print("Document link check failed.")
|
||||
return 1
|
||||
|
||||
print("All document links passed.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,232 +0,0 @@
|
||||
#!/usr/bin/env sh
|
||||
# 文档链接有效性检查脚本
|
||||
|
||||
set -eu
|
||||
|
||||
echo "========================================"
|
||||
echo "🔗 文档链接有效性检查"
|
||||
echo "========================================"
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PLAYBOOK_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
|
||||
TOTAL_LINKS=0
|
||||
VALID_LINKS=0
|
||||
BROKEN_LINKS=0
|
||||
SKIPPED_LINKS=0
|
||||
|
||||
BROKEN_LINKS_FILE="$(mktemp "${TMPDIR:-/tmp}/broken_links.XXXXXX")"
|
||||
REPORT_FILE="$(mktemp "${TMPDIR:-/tmp}/doc_links_report.XXXXXX")"
|
||||
|
||||
echo "📁 Playbook 根目录: $PLAYBOOK_ROOT"
|
||||
echo ""
|
||||
|
||||
# ============================================
|
||||
# 辅助函数
|
||||
# ============================================
|
||||
|
||||
check_file_link() {
|
||||
local source_file="$1"
|
||||
local link_path="$2"
|
||||
local link_line="$3"
|
||||
|
||||
TOTAL_LINKS=$((TOTAL_LINKS + 1))
|
||||
|
||||
# 处理相对路径
|
||||
local source_dir
|
||||
source_dir="$(dirname "$source_file")"
|
||||
|
||||
# 解析链接路径
|
||||
local target_path="$link_path"
|
||||
|
||||
# 移除锚点
|
||||
target_path="${target_path%%#*}"
|
||||
|
||||
# 跳过空链接
|
||||
if [ -z "$target_path" ]; then
|
||||
SKIPPED_LINKS=$((SKIPPED_LINKS + 1))
|
||||
return 0
|
||||
fi
|
||||
|
||||
# 跳过外部链接(http/https)
|
||||
if echo "$target_path" | grep -qE "^https?://"; then
|
||||
SKIPPED_LINKS=$((SKIPPED_LINKS + 1))
|
||||
return 0
|
||||
fi
|
||||
|
||||
# 跳过 mailto 链接
|
||||
if echo "$target_path" | grep -q "^mailto:"; then
|
||||
SKIPPED_LINKS=$((SKIPPED_LINKS + 1))
|
||||
return 0
|
||||
fi
|
||||
|
||||
# 跳过第三方文档的根路径链接(不带协议的外部路径)
|
||||
case "$target_path" in
|
||||
/en/docs/*)
|
||||
SKIPPED_LINKS=$((SKIPPED_LINKS + 1))
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
|
||||
# 构建绝对路径
|
||||
local absolute_path
|
||||
if echo "$target_path" | grep -q "^/"; then
|
||||
# 绝对路径(从仓库根)
|
||||
absolute_path="$PLAYBOOK_ROOT$target_path"
|
||||
else
|
||||
# 相对路径
|
||||
absolute_path="$source_dir/$target_path"
|
||||
fi
|
||||
|
||||
# 规范化路径
|
||||
absolute_path="$(cd "$(dirname "$absolute_path")" 2>/dev/null && pwd)/$(basename "$absolute_path")" || absolute_path=""
|
||||
|
||||
# 检查文件是否存在
|
||||
if [ -n "$absolute_path" ] && [ -e "$absolute_path" ]; then
|
||||
VALID_LINKS=$((VALID_LINKS + 1))
|
||||
return 0
|
||||
else
|
||||
BROKEN_LINKS=$((BROKEN_LINKS + 1))
|
||||
echo "❌ 断链: $source_file:$link_line" >> "$BROKEN_LINKS_FILE"
|
||||
echo " 链接: $link_path" >> "$BROKEN_LINKS_FILE"
|
||||
echo " 目标: $absolute_path" >> "$BROKEN_LINKS_FILE"
|
||||
echo "" >> "$BROKEN_LINKS_FILE"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
extract_links() {
|
||||
awk '
|
||||
BEGIN { in_code = 0 }
|
||||
{
|
||||
line = $0
|
||||
if (line ~ /^```/) { in_code = !in_code; next }
|
||||
if (in_code) next
|
||||
|
||||
gsub(/`[^`]*`/, "", line)
|
||||
|
||||
while (match(line, /\[[^]]+\]\([^)]*\)/)) {
|
||||
link = substr(line, RSTART, RLENGTH)
|
||||
sub(/^\[[^]]+\]\(/, "", link)
|
||||
sub(/\)$/, "", link)
|
||||
print NR "\t" link
|
||||
line = substr(line, RSTART + RLENGTH)
|
||||
}
|
||||
|
||||
if (match(line, /^\[[^]]+\]:[[:space:]]*[^[:space:]]+/)) {
|
||||
link = substr(line, RSTART, RLENGTH)
|
||||
sub(/^\[[^]]+\]:[[:space:]]*/, "", link)
|
||||
sub(/[[:space:]].*$/, "", link)
|
||||
print NR "\t" link
|
||||
}
|
||||
}
|
||||
' "$1"
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# 查找并检查所有 Markdown 文件
|
||||
# ============================================
|
||||
|
||||
echo "🔍 扫描 Markdown 文件..."
|
||||
|
||||
cd "$PLAYBOOK_ROOT"
|
||||
|
||||
MD_FILES=$(find . -name "*.md" \
|
||||
-not -name "*.template.md" \
|
||||
-not -path "*/node_modules/*" \
|
||||
-not -path "*/.git/*" \
|
||||
-not -path "*/build/*" \
|
||||
-not -path "*/dist/*" \
|
||||
2>/dev/null || true)
|
||||
|
||||
FILE_COUNT=$(echo "$MD_FILES" | grep -c "^" || echo 0)
|
||||
echo "📄 找到 $FILE_COUNT 个 Markdown 文件"
|
||||
echo ""
|
||||
|
||||
CURRENT_FILE_NUM=0
|
||||
|
||||
for md_file in $MD_FILES; do
|
||||
CURRENT_FILE_NUM=$((CURRENT_FILE_NUM + 1))
|
||||
|
||||
# 显示进度
|
||||
if [ "$CURRENT_FILE_NUM" -eq 1 ] || [ $((CURRENT_FILE_NUM % 10)) -eq 0 ] || [ "$CURRENT_FILE_NUM" -eq "$FILE_COUNT" ]; then
|
||||
echo "📖 处理中... [$CURRENT_FILE_NUM/$FILE_COUNT] $md_file"
|
||||
fi
|
||||
|
||||
links_file="$(mktemp)"
|
||||
extract_links "$md_file" > "$links_file"
|
||||
while IFS="$(printf '\t')" read -r line_num link; do
|
||||
check_file_link "$md_file" "$link" "$line_num" || true
|
||||
done < "$links_file"
|
||||
rm -f "$links_file"
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "✅ 扫描完成"
|
||||
echo ""
|
||||
|
||||
# ============================================
|
||||
# 生成检查报告
|
||||
# ============================================
|
||||
|
||||
echo "========================================"
|
||||
echo "📊 链接检查结果统计"
|
||||
echo "========================================"
|
||||
echo "🔗 总链接数: $TOTAL_LINKS"
|
||||
echo "✅ 有效链接: $VALID_LINKS"
|
||||
echo "⏭️ 跳过链接: $SKIPPED_LINKS (外部/mailto)"
|
||||
echo "❌ 断开链接: $BROKEN_LINKS"
|
||||
|
||||
if [ "$TOTAL_LINKS" -gt 0 ]; then
|
||||
CHECKED_LINKS=$((TOTAL_LINKS - SKIPPED_LINKS))
|
||||
if [ "$CHECKED_LINKS" -gt 0 ]; then
|
||||
SUCCESS_RATE=$(awk "BEGIN {printf \"%.1f\", ($VALID_LINKS * 100.0) / $CHECKED_LINKS}")
|
||||
echo "📈 有效率: $SUCCESS_RATE%"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# 写入报告
|
||||
{
|
||||
echo "文档链接有效性检查报告"
|
||||
echo "========================"
|
||||
echo ""
|
||||
echo "检查时间: $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
echo "检查目录: $PLAYBOOK_ROOT"
|
||||
echo ""
|
||||
echo "统计结果:"
|
||||
echo " 总链接数: $TOTAL_LINKS"
|
||||
echo " 有效链接: $VALID_LINKS"
|
||||
echo " 跳过链接: $SKIPPED_LINKS"
|
||||
echo " 断开链接: $BROKEN_LINKS"
|
||||
echo ""
|
||||
if [ "$BROKEN_LINKS" -gt 0 ]; then
|
||||
echo "断开链接详情:"
|
||||
echo "=============="
|
||||
cat "$BROKEN_LINKS_FILE"
|
||||
fi
|
||||
} > "$REPORT_FILE"
|
||||
|
||||
if [ "$BROKEN_LINKS" -gt 0 ]; then
|
||||
echo "❌ 发现 $BROKEN_LINKS 个断开的链接"
|
||||
echo ""
|
||||
echo "断开链接详情:"
|
||||
cat "$BROKEN_LINKS_FILE"
|
||||
echo ""
|
||||
echo "📄 详细报告: $REPORT_FILE"
|
||||
fi
|
||||
|
||||
echo "========================================"
|
||||
|
||||
# 清理临时文件(保留报告用于 CI)
|
||||
# rm -f "$BROKEN_LINKS_FILE"
|
||||
|
||||
# 返回结果
|
||||
if [ "$BROKEN_LINKS" -eq 0 ]; then
|
||||
echo "✅ 所有文档链接检查通过"
|
||||
exit 0
|
||||
else
|
||||
echo "❌ 文档链接检查失败"
|
||||
exit 1
|
||||
fi
|
||||
Reference in New Issue
Block a user