mirror of
https://github.com/tradecatlabs/vibe-coding-cn.git
synced 2026-07-27 18:57:50 +00:00
f1f0b9fe75
Move the complete research domain from docs/research to root-level research, update indexes and path-aware tooling, and align raw snapshot governance and CODEOWNERS.\n\nVerified with make test and GitHub CI.
271 lines
9.2 KiB
Python
271 lines
9.2 KiB
Python
#!/usr/bin/env python3
|
|
"""Check docs README standard blocks, stable anchors and duplicate manual anchors."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
from lib.taxonomy import doc_readmes_from_taxonomy, taxonomy_sections
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
SKIP_PARTS = {".git", ".history", "node_modules"}
|
|
SKIP_PREFIXES = [
|
|
Path(".github/wiki"),
|
|
Path("tools/external"),
|
|
]
|
|
ANCHOR_PATTERN = re.compile(r"<a\s+id=[\"']([^\"']+)[\"']")
|
|
HEADING_PATTERN = re.compile(r"^(#{1,6})\s+(.+?)\s*#*\s*$")
|
|
SUMMARY_LINE = "<summary><strong>完整细粒度目录(点击展开/收起)</strong></summary>"
|
|
STANDARD_README_BLOCKS = [
|
|
("顶部标题块", re.compile(r"^#\s+.+$", re.MULTILINE)),
|
|
("字多不看", re.compile(r"^##\s+字多不看\s*$", re.MULTILINE)),
|
|
("快速导航", re.compile(r"^##\s+快速导航\s*$", re.MULTILINE)),
|
|
("完整细粒度目录", re.compile(re.escape(SUMMARY_LINE))),
|
|
("使用方式", re.compile(r"^##\s+使用方式\s*$", re.MULTILINE)),
|
|
]
|
|
DISALLOWED_README_HEADINGS = {"和其他目录的边界", "维护规则"}
|
|
|
|
|
|
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 should_skip(path: Path) -> bool:
|
|
rel = path.relative_to(ROOT)
|
|
if any(part in SKIP_PARTS for part in rel.parts):
|
|
return True
|
|
if is_research_repository_snapshot(rel):
|
|
return True
|
|
return any(rel == prefix or prefix in rel.parents for prefix in SKIP_PREFIXES)
|
|
|
|
|
|
def is_research_repository_snapshot(rel: Path) -> bool:
|
|
parts = rel.parts
|
|
return (
|
|
len(parts) >= 4
|
|
and parts[0] == "research"
|
|
and parts[2] == "raw"
|
|
and parts[3] == "repository"
|
|
)
|
|
|
|
|
|
def duplicate_manual_anchors(path: Path) -> list[str]:
|
|
text = strip_fenced_code(path.read_text(encoding="utf-8", errors="ignore"))
|
|
seen: dict[str, int] = {}
|
|
errors: list[str] = []
|
|
|
|
for lineno, line in enumerate(text.splitlines(), start=1):
|
|
for anchor in ANCHOR_PATTERN.findall(line):
|
|
if anchor in seen:
|
|
rel = path.relative_to(ROOT)
|
|
errors.append(f"{rel}:{lineno}: duplicate manual anchor '{anchor}', first seen at line {seen[anchor]}")
|
|
else:
|
|
seen[anchor] = lineno
|
|
|
|
return errors
|
|
|
|
|
|
def check_linear_readme(path: Path, expected_anchors: list[str]) -> list[str]:
|
|
rel = path.relative_to(ROOT)
|
|
text = strip_fenced_code(path.read_text(encoding="utf-8", errors="ignore"))
|
|
errors: list[str] = []
|
|
|
|
errors.extend(check_standard_readme_blocks(path, text))
|
|
|
|
if "完整细粒度目录(点击展开/收起)" not in text:
|
|
errors.append(f"{rel}: missing collapsible full fine-grained table of contents")
|
|
|
|
if "<details>" not in text or "</details>" not in text:
|
|
errors.append(f"{rel}: missing details block for long-form navigation")
|
|
|
|
anchor_lines: dict[str, int] = {}
|
|
for lineno, line in enumerate(text.splitlines(), start=1):
|
|
for anchor in ANCHOR_PATTERN.findall(line):
|
|
anchor_lines.setdefault(anchor, lineno)
|
|
|
|
previous_line = 0
|
|
for anchor in expected_anchors:
|
|
line = anchor_lines.get(anchor)
|
|
if line is None:
|
|
errors.append(f"{rel}: missing required main anchor '{anchor}'")
|
|
continue
|
|
if line <= previous_line:
|
|
errors.append(f"{rel}:{line}: main anchor '{anchor}' is out of expected section order")
|
|
previous_line = line
|
|
|
|
toc_text = active_toc_text(text)
|
|
if not toc_text:
|
|
errors.append(f"{rel}: missing active collapsible fine-grained TOC block")
|
|
|
|
for anchor in expected_anchors:
|
|
if f"#{anchor}" not in toc_text:
|
|
errors.append(f"{rel}: top navigation or fine-grained TOC does not link to '#{anchor}'")
|
|
|
|
required_prefixes = tuple(f"{anchor}-" for anchor in expected_anchors)
|
|
for anchor in anchor_lines:
|
|
if anchor in expected_anchors or anchor.startswith(required_prefixes):
|
|
if f"#{anchor}" not in toc_text:
|
|
errors.append(f"{rel}: fine-grained TOC does not link to manual anchor '#{anchor}'")
|
|
|
|
return errors
|
|
|
|
|
|
def check_standard_readme_blocks(path: Path, text: str) -> list[str]:
|
|
"""Validate the standard top-level README block order used under docs/."""
|
|
rel = path.relative_to(ROOT)
|
|
errors: list[str] = []
|
|
positions: list[tuple[str, int]] = []
|
|
|
|
for lineno, line in enumerate(text.splitlines(), start=1):
|
|
heading = HEADING_PATTERN.match(line.strip())
|
|
if heading and heading.group(2).strip() in DISALLOWED_README_HEADINGS:
|
|
errors.append(f"{rel}:{lineno}: README must not contain heading '{heading.group(2).strip()}'")
|
|
|
|
for block_name, pattern in STANDARD_README_BLOCKS:
|
|
match = pattern.search(text)
|
|
if match is None:
|
|
errors.append(f"{rel}: missing standard README block '{block_name}'")
|
|
continue
|
|
positions.append((block_name, match.start()))
|
|
|
|
if len(positions) != len(STANDARD_README_BLOCKS):
|
|
return errors
|
|
|
|
title_end = positions[0][1] + text[positions[0][1] :].find("\n")
|
|
if title_end < positions[0][1]:
|
|
title_end = len(text)
|
|
between_title_and_summary = text[title_end:positions[1][1]].strip()
|
|
if between_title_and_summary:
|
|
errors.append(f"{rel}: standard README block '字多不看' must immediately follow the top title")
|
|
|
|
previous_name, previous_pos = positions[0]
|
|
for block_name, position in positions[1:]:
|
|
if position <= previous_pos:
|
|
errors.append(
|
|
f"{rel}: standard README block '{block_name}' must appear after '{previous_name}'"
|
|
)
|
|
previous_name, previous_pos = block_name, position
|
|
|
|
return errors
|
|
|
|
|
|
def active_toc_text(text: str) -> str:
|
|
in_fence = False
|
|
fence_marker = ""
|
|
collecting = False
|
|
collected: list[str] = []
|
|
|
|
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 = ""
|
|
|
|
if in_fence:
|
|
continue
|
|
if SUMMARY_LINE in line:
|
|
collecting = True
|
|
if collecting:
|
|
collected.append(line)
|
|
if collecting and line.strip() == "</details>":
|
|
break
|
|
|
|
return "\n".join(collected)
|
|
|
|
|
|
def check_docs_index() -> list[str]:
|
|
errors: list[str] = []
|
|
docs_index = ROOT / "docs/README.md"
|
|
rel = docs_index.relative_to(ROOT)
|
|
|
|
if not docs_index.exists():
|
|
return [f"{rel}: missing docs index"]
|
|
|
|
text = docs_index.read_text(encoding="utf-8", errors="ignore")
|
|
for name, fields in taxonomy_sections().items():
|
|
for field in ("path", "entry", "agent_guide"):
|
|
value = fields.get(field)
|
|
if not value:
|
|
errors.append(f"metadata/taxonomy.yml: section '{name}' missing '{field}'")
|
|
continue
|
|
docs_relative = value.removeprefix("docs/")
|
|
candidates = {value, f"./{docs_relative}", docs_relative}
|
|
if not any(candidate in text for candidate in candidates):
|
|
errors.append(f"{rel}: missing taxonomy {field} reference for '{name}': {value}")
|
|
|
|
return errors
|
|
|
|
|
|
def main() -> int:
|
|
errors: list[str] = []
|
|
checked_files = 0
|
|
|
|
for markdown_file in sorted(ROOT.rglob("*.md")):
|
|
if should_skip(markdown_file):
|
|
continue
|
|
checked_files += 1
|
|
errors.extend(duplicate_manual_anchors(markdown_file))
|
|
|
|
doc_readmes = doc_readmes_from_taxonomy()
|
|
for rel_path, expected_anchors in doc_readmes.items():
|
|
path = ROOT / rel_path
|
|
if not path.exists():
|
|
errors.append(f"{rel_path}: missing docs README")
|
|
continue
|
|
errors.extend(check_linear_readme(path, expected_anchors))
|
|
|
|
readme_paths = {Path("docs/README.md")}
|
|
for fields in taxonomy_sections().values():
|
|
entry = fields.get("entry", "")
|
|
if entry.endswith("/README.md"):
|
|
readme_paths.add(Path(entry))
|
|
|
|
for rel_path in sorted(readme_paths):
|
|
path = ROOT / rel_path
|
|
if not path.exists():
|
|
errors.append(f"{rel_path}: missing docs README")
|
|
continue
|
|
text = strip_fenced_code(path.read_text(encoding="utf-8", errors="ignore"))
|
|
errors.extend(check_standard_readme_blocks(path, text))
|
|
|
|
errors.extend(check_docs_index())
|
|
|
|
if errors:
|
|
print("DOC_STRUCTURE_ERRORS")
|
|
for error in errors:
|
|
print(error)
|
|
print(f"TOTAL={len(errors)}")
|
|
return 1
|
|
|
|
print(f"OK docs structure checked: {checked_files} markdown files")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|