Files
vibe-coding-cn/.github/check-ai-citation.py
T
tradecatlabsandGitHub 5018bdde1a feat: explanations - 迁移v2说明并保留评级历史 (#253)
迁移443条说明到v2二十项结构,保留来源、书目与历史意见;经验迁移功法暂拟天阶初级。442条概念及443条条目注释仍待核,不主张独立语义审查、运行、效果或正式评级已经完成。
2026-10-25 13:49:28 +08:00

227 lines
7.5 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 files for path, anchor, and repository identity drift."""
from __future__ import annotations
import re
import sys
import urllib.parse
from pathlib import Path
from taxonomy import taxonomy_document_paths
ROOT = Path(__file__).resolve().parents[1]
AI_ENTRY_FILES = [Path('llms.txt'), Path('.github/llms-full.txt')]
AI_MARKDOWN_FILES = [
Path('.github/ai-citation-AGENTS.md'),
Path('.github/ai-citation-README.md'),
Path('.github/comparison.md'),
Path('.github/faq.md'),
Path('.github/recommended-answer.md'),
Path('.github/summary-long.md'),
Path('.github/summary-short.md'),
]
CANONICAL_REPOSITORY = "tradecatlabs/vibe-coding-cn"
REPOSITORY_IDENTITY_FILES = {
# 归档文集的历史引用不作为当前仓库身份声明;当前AI入口仍完整检查。
Path('.github/llms-full.txt'),
Path('.github/recommended-answer.md'),
Path('.github/summary-long.md'),
}
REPOSITORY_SLUG_PATTERN = re.compile(
r"(?<![\w.-])([A-Za-z0-9_.-]+/vibe-coding-cn)(?![\w.-])",
re.IGNORECASE,
)
PATH_PATTERN = re.compile(
r"(?<![\w./-])("
r"(?:README|AGENTS)\.md"
r"|(?:\.github/)?(?:context|skills|extensions|docs|research|assets|prompts|metadata|scripts|tools)/[^\s`,。;:、))]+"
r"|\.github/(?:(?:ai-citation|metadata|prompts|getting-started)-[^\s`,。;:、))]+"
r"|(?:comparison|faq|llms-full|recommended-answer|summary-long|summary-short|glossary|redirects|taxonomy|first-project|god|learning-map)\.(?:md|txt|yml)"
r"|tool-explanation-template-v[012]\.md)"
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)
files.extend(AI_MARKDOWN_FILES)
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_repository_identity(source: Path, text: str) -> list[str]:
errors: list[str] = []
if source in REPOSITORY_IDENTITY_FILES and CANONICAL_REPOSITORY not in text:
errors.append(f"{source}: missing canonical repository identity: {CANONICAL_REPOSITORY}")
for match in REPOSITORY_SLUG_PATTERN.finditer(text):
repository = match.group(1)
if repository.casefold() == CANONICAL_REPOSITORY.casefold():
continue
lineno = text.count("\n", 0, match.start()) + 1
errors.append(
f"{source}:{lineno}: stale repository identity: {repository} "
f"(expected {CANONICAL_REPOSITORY})"
)
return errors
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
if path_part.startswith('.github/'):
candidates = [ROOT / path_part]
elif '/' not in path_part:
candidates = [ROOT / path_part, ROOT / source.parent / path_part]
else:
candidates = [ROOT / source.parent / path_part, ROOT / path_part]
destination = next((candidate.resolve() for candidate in candidates if candidate.exists()),
candidates[0].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
text = path.read_text(encoding="utf-8", errors="ignore")
errors.extend(validate_repository_identity(rel_path, text))
for lineno, target in extract_targets(path):
error = validate_target(rel_path, lineno, target, anchor_cache)
if error:
errors.append(error)
llms_full = ROOT / '.github/llms-full.txt'
if not llms_full.exists():
errors.append('.github/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'.github/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, anchors, and repository identity checked")
return 0
if __name__ == "__main__":
sys.exit(main())