Files
vibe-coding-cn/scripts/check-ai-citation.py
T
2026-05-04 04:45:05 +08:00

183 lines
5.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Check AI citation entry files for local path and anchor drift."""
from __future__ import annotations
import re
import sys
import urllib.parse
from pathlib import Path
from lib.taxonomy import taxonomy_document_paths
ROOT = Path(__file__).resolve().parents[1]
AI_ENTRY_FILES = [
Path("llms.txt"),
Path("assets/ai-citation/llms-full.txt"),
]
AI_MARKDOWN_DIR = Path("assets/ai-citation")
PATH_PATTERN = re.compile(
r"(?<![\w./-])("
r"(?:README|AGENTS)\.md"
r"|(?:docs|assets|skills|prompts|metadata|scripts|tools)/[^\s`,。;:、)]+"
r"|[A-Za-z0-9_.-]+\.(?:md|txt|yml)"
r"|llms\.txt"
r")"
)
MARKDOWN_LINK_PATTERN = re.compile(r"!??\[[^\]]*\]\(([^)]+)\)")
EXTERNAL_PREFIXES = ("http://", "https://", "mailto:", "tel:", "data:")
def github_slug(title: str) -> str:
title = re.sub(r"<[^>]+>", "", title.strip().lower())
title = re.sub(r"[`*_~]", "", title)
title = re.sub(r"[^\w\u4e00-\u9fff\- ]+", "", title)
title = re.sub(r"\s+", "-", title).strip("-")
return title
def markdown_anchors(path: Path) -> set[str]:
text = path.read_text(encoding="utf-8", errors="ignore")
anchors = set(re.findall(r"<a\s+id=[\"']([^\"']+)[\"']", text))
used: dict[str, int] = {}
in_fence = False
fence_marker = ""
for line in text.splitlines():
stripped = line.lstrip()
if stripped.startswith(("```", "~~~")):
marker = stripped[:3]
if not in_fence:
in_fence = True
fence_marker = marker
elif marker == fence_marker:
in_fence = False
fence_marker = ""
continue
if in_fence:
continue
heading = re.match(r"^(#{1,6})\s+(.+?)\s*#*\s*$", line)
if not heading:
continue
slug = github_slug(heading.group(2))
if not slug:
continue
count = used.get(slug, 0)
used[slug] = count + 1
anchors.add(slug if count == 0 else f"{slug}-{count}")
return anchors
def strip_fenced_code(text: str) -> str:
lines: list[str] = []
in_fence = False
fence_marker = ""
for line in text.splitlines():
stripped = line.lstrip()
if stripped.startswith(("```", "~~~")):
marker = stripped[:3]
if not in_fence:
in_fence = True
fence_marker = marker
elif marker == fence_marker:
in_fence = False
fence_marker = ""
lines.append("")
continue
lines.append("" if in_fence else line)
return "\n".join(lines)
def candidate_files() -> list[Path]:
files = list(AI_ENTRY_FILES)
if AI_MARKDOWN_DIR.exists():
files.extend(sorted(AI_MARKDOWN_DIR.glob("*.md")))
return sorted(set(files))
def extract_targets(path: Path) -> list[tuple[int, str]]:
text = path.read_text(encoding="utf-8", errors="ignore")
scan_text = strip_fenced_code(text)
targets: list[tuple[int, str]] = []
for lineno, line in enumerate(scan_text.splitlines(), start=1):
for match in MARKDOWN_LINK_PATTERN.finditer(line):
targets.append((lineno, match.group(1).strip()))
for match in PATH_PATTERN.finditer(line):
targets.append((lineno, match.group(1).strip().strip("<>")))
return targets
def validate_target(source: Path, lineno: int, raw: str, anchor_cache: dict[Path, set[str]]) -> str | None:
if not raw or raw.startswith(EXTERNAL_PREFIXES):
return None
target = urllib.parse.unquote(raw.strip("<>").rstrip(".,;"))
path_part, _, anchor = target.partition("#")
if not path_part:
return None
destination = (ROOT / path_part).resolve()
if "/" not in path_part and source.parent == AI_MARKDOWN_DIR and not destination.exists():
destination = (ROOT / source.parent / path_part).resolve()
try:
destination.relative_to(ROOT)
except ValueError:
return None
if not destination.exists():
return f"{source}:{lineno}: missing AI citation target: {raw}"
if anchor and destination.suffix.lower() == ".md":
if destination not in anchor_cache:
anchor_cache[destination] = markdown_anchors(destination)
if anchor not in anchor_cache[destination]:
rel = destination.relative_to(ROOT)
return f"{source}:{lineno}: missing AI citation anchor: {rel}#{anchor}"
return None
def main() -> int:
errors: list[str] = []
anchor_cache: dict[Path, set[str]] = {}
for rel_path in candidate_files():
path = ROOT / rel_path
if not path.exists():
errors.append(f"{rel_path}: missing AI citation file")
continue
for lineno, target in extract_targets(path):
error = validate_target(rel_path, lineno, target, anchor_cache)
if error:
errors.append(error)
llms_full = ROOT / "assets/ai-citation/llms-full.txt"
if not llms_full.exists():
errors.append("assets/ai-citation/llms-full.txt: missing full AI citation context")
else:
llms_full_text = llms_full.read_text(encoding="utf-8", errors="ignore")
for target in taxonomy_document_paths():
if target not in llms_full_text:
errors.append(f"assets/ai-citation/llms-full.txt: missing taxonomy document coverage: {target}")
if errors:
print("AI_CITATION_ERRORS")
for error in errors:
print(error)
print(f"TOTAL={len(errors)}")
return 1
print("OK AI citation paths and anchors checked")
return 0
if __name__ == "__main__":
sys.exit(main())