Files
claude-howto/scripts/check_cross_references.py
T
Luong NGUYEN 6d1e0ae4af refactor(ci): shift quality checks to pre-commit, CI as 2nd pass (#34)
* refactor(ci): shift quality checks to pre-commit, CI as 2nd pass

- Remove ci.yml (lint, security, pytest were only for EPUB scripts)
- Move EPUB build to pre-commit local hook (runs on .md changes)
- Add check_cross_references.py, check_mermaid.py, check_links.py scripts
- Add markdown-lint, cross-references, mermaid-syntax, link-check as
  pre-commit hooks — mirrors all 4 CI doc-check jobs locally
- Remove spell check job from docs-check.yml (breaks on translations)
- Refactor docs-check.yml to reuse scripts/ instead of inline Python
- Add .markdownlint.json config shared by pre-commit and CI
- Update CONTRIBUTING.md with required dependencies and hook table

* fix(ci): resolve all CI check failures in docs-check workflow

- fix(check_cross_references): skip code blocks and inline code spans
  to avoid false positives from documentation examples; fix emoji
  heading anchor generation (rstrip not strip); add blog-posts,
  openspec, prompts, .agents to IGNORE_DIRS; ignore README.backup.md
- fix(check_links): strip trailing Markdown punctuation from captured
  URLs; add wikipedia, api.github.com to SKIP_DOMAINS; add placeholder
  URL patterns to SKIP_URL_PATTERNS; add .agents/.claude to IGNORE_DIRS
- fix(check_mermaid): add --no-sandbox puppeteer config support via
  MERMAID_PUPPETEER_NO_SANDBOX env var for GitHub Actions Linux runners
- fix(docs-check.yml): pass MERMAID_PUPPETEER_NO_SANDBOX=true to mermaid job
- fix(content): repair broken anchors in README.md, 09-advanced-features;
  fix #plugins -> #claude-code-plugins in claude_concepts_guide.md;
  remove non-existent ./docs/performance.md placeholder links; fix
  dependabot alerts URL in SECURITY_REPORTING.md; update auto-mode URL
  in resources.md; use placeholder pattern for 07-plugins example URL
- remove README.backup.md (stale file)

* fix(check-scripts): fix strip_code_blocks regex and URL fragment handling

- fix regex in strip_code_blocks to avoid conflicting MULTILINE+DOTALL
  flags that could fail to strip indented code fences; use DOTALL only
- strip URL fragments (#section) before dispatching link checks to avoid
  false-positive 404s on valid URLs with anchor fragments

* fix(check-scripts): fix anchor stripping, cross-ref enforcement, and mermaid temp file cleanup

- heading_to_anchor: use .strip("-") instead of .rstrip("-") to also strip leading hyphens
  produced by emoji-prefixed headings, preventing false-positive anchor errors
- check_cross_references: always exit with main()'s return code — filesystem checks
  should block pre-commit unconditionally, not silently pass on errors
- check_mermaid: wrap file-processing loop in try/finally so the puppeteer config
  temp file is cleaned up even if an unexpected exception (e.g. UnicodeDecodeError) occurs
- docs-check.yml: remove now-unused CROSS_REF_STRICT env var

* fix(scripts): fix anchor stripping and mermaid output path

- Replace .strip('-') with .rstrip('-') in heading_to_anchor() so leading
  hyphens from emoji-prefixed headings are preserved, matching GitHub's
  anchor generation behaviour.
- Use Path.with_suffix('.svg') in check_mermaid.py instead of
  str.replace('.mmd', '.svg') to avoid replacing all occurrences of .mmd
  in the full temp path.
2026-04-02 02:20:45 +02:00

97 lines
3.0 KiB
Python

#!/usr/bin/env python3
"""Validate cross-references, anchors, and code fences in Markdown files."""
import re
import sys
from pathlib import Path
IGNORE_DIRS = {
".venv",
"node_modules",
".git",
"blog-posts",
"openspec",
"prompts",
".agents",
}
IGNORE_FILES = {"README.backup.md"}
def iter_md_files():
for f in Path().rglob("*.md"):
if (
not any(part in IGNORE_DIRS for part in f.parts)
and f.name not in IGNORE_FILES
):
yield f
def heading_to_anchor(heading: str) -> str:
# Match GitHub's anchor generation: strip non-ASCII (emoji), strip punctuation,
# lowercase, replace spaces with hyphens, strip leading/trailing hyphens.
heading_ascii = heading.encode("ascii", "ignore").decode()
return re.sub(r"[^\w\s-]", "", heading_ascii.lower()).replace(" ", "-").rstrip("-")
def strip_code_blocks(content: str) -> str:
"""Remove fenced code blocks and inline code spans to avoid scanning example links."""
# Strip fenced code blocks (``` ... ```)
content = re.sub(r"```[^\n]*\n.*?```", "", content, flags=re.DOTALL)
# Strip inline code spans (` ... `)
content = re.sub(r"`[^`\n]+`", "", content)
return content
def main() -> int:
errors = []
for file_path in iter_md_files():
content = file_path.read_text()
# Strip code blocks before scanning for links/anchors to avoid false positives
# from documentation examples inside code fences.
scannable = strip_code_blocks(content)
# Relative .md links must resolve
errors.extend(
f"{file_path}: broken cross-reference → '{link_path}'"
for link_path in re.findall(r"\[[^\]]+\]\(([^)#]+\.md)[^)]*\)", scannable)
if not (file_path.parent / link_path).resolve().exists()
)
# In-page anchors must match a real heading
anchors = re.findall(r"\[[^\]]+\]\(#([^)]+)\)", scannable)
if anchors:
headings = re.findall(r"^#{1,6}\s+(.+)$", content, re.MULTILINE)
valid_anchors = {heading_to_anchor(h) for h in headings}
errors.extend(
f"{file_path}: broken anchor → '#{anchor}'"
for anchor in anchors
if anchor not in valid_anchors
)
# Unmatched code fences (only count fences at start of line)
if len(re.findall(r"^```", content, re.MULTILINE)) % 2 != 0:
errors.append(f"{file_path}: unmatched code fences")
# All numbered lesson dirs must have README.md
for i in range(1, 11):
errors.extend(
f"{d}: missing README.md"
for d in Path().glob(f"{i:02d}-*")
if d.is_dir() and not (d / "README.md").exists()
)
if errors:
print("❌ Cross-reference errors:")
for e in errors:
print(f" - {e}")
return 1
md_count = sum(1 for _ in iter_md_files())
print(f"✅ All cross-references valid ({md_count} files checked)")
return 0
if __name__ == "__main__":
sys.exit(main())