2026-05-04 01:25:22 +08:00
|
|
|
|
#!/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
|
|
|
|
|
|
|
2026-05-04 04:45:05 +08:00
|
|
|
|
from lib.taxonomy import taxonomy_document_paths
|
2026-05-04 01:25:22 +08:00
|
|
|
|
|
|
|
|
|
|
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"
|
2026-07-11 22:18:37 +08:00
|
|
|
|
r"|(?:docs|research|assets|skills|prompts|metadata|scripts|tools)/[^\s`,。;:、))]+"
|
2026-05-04 01:25:22 +08:00
|
|
|
|
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)
|
|
|
|
|
|
|
2026-05-04 03:47:52 +08:00
|
|
|
|
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}")
|
|
|
|
|
|
|
2026-05-04 01:25:22 +08:00
|
|
|
|
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())
|