diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 0983fd1e0..dfc46af3a 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -25,6 +25,8 @@ jobs: run: python3 scripts/validate-skills.py - name: Check markdown links run: python3 scripts/check-links.py + - name: Run markdown links checker tests + run: python3 scripts/test_check_links.py - name: Check ASCII punctuation in source files # ai-config#2550: ensure .py and .R source files obey the ASCII punctuation policy. run: python3 scripts/check-ascii-punctuation.py diff --git a/scripts/check-links.py b/scripts/check-links.py index 7fa4666c6..a8e096ea2 100755 --- a/scripts/check-links.py +++ b/scripts/check-links.py @@ -20,6 +20,7 @@ ROOT = Path(__file__).resolve().parent.parent LINK = re.compile(r"\[[^\]]*\]\(([^)]+)\)") +AUTOLINK = re.compile(r"<([a-zA-Z][a-zA-Z0-9+.-]*:[^<>\s]+|[^<>\s]+@[^<>\s]+)>") # Strip code regions first so link-shaped examples inside fences / backticks # (regexes, `[text](url)` snippets) aren't mistaken for real links. INLINE = re.compile(r"`[^`]*`") @@ -40,33 +41,61 @@ def is_external(target: str) -> bool: - return target.startswith(SKIP_PREFIXES) or "://" in target + return ( + target.startswith(SKIP_PREFIXES) + or "://" in target + or ("@" in target and ":" not in target and "/" not in target) + ) -def check_file(md: Path) -> None: - global checked - text = md.read_text(encoding="utf-8") +def extract_targets(text: str) -> list[str]: + """Extract link targets from markdown text after stripping code blocks.""" text = strip_fences(text) text = INLINE.sub("", text) + targets: list[str] = [] for match in LINK.finditer(text): target = match.group(1).strip() if target.startswith("<") and target.endswith(">"): target = target[1:-1].strip() # drop a trailing `"title"` if present target = target.split(" ", 1)[0] - if not target or is_external(target): - continue - if "<" in target or ">" in target: - continue # angle-bracket placeholder, e.g. / - path_part = re.split(r"[#?]", target, maxsplit=1)[0] - if not path_part: # pure in-page anchor - continue - if "/" not in path_part and "." not in path_part: - continue # bare-word placeholder in an example, e.g. (url) - checked += 1 - resolved = (md.parent / path_part).resolve() - if not resolved.exists(): - broken.append(f"{md.relative_to(ROOT)} -> {target}") + if target: + targets.append(target) + # Strip standard links before scanning autolinks so destinations in `[text]()` + # are not matched twice. + text_without_links = LINK.sub("", text) + for match in AUTOLINK.finditer(text_without_links): + target = match.group(1).strip() + if target: + targets.append(target) + return targets + + +def check_target(target: str, md: Path) -> None: + global checked + if not target or is_external(target): + return + if "<" in target or ">" in target: + return # angle-bracket placeholder, e.g. / + path_part = re.split(r"[#?]", target, maxsplit=1)[0] + if not path_part: # pure in-page anchor + return + if "/" not in path_part and "." not in path_part: + return # bare-word placeholder in an example, e.g. (url) + checked += 1 + resolved = (md.parent / path_part).resolve() + if not resolved.exists(): + try: + rel_path = md.relative_to(ROOT) + except ValueError: + rel_path = md + broken.append(f"{rel_path} -> {target}") + + +def check_file(md: Path) -> None: + text = md.read_text(encoding="utf-8") + for target in extract_targets(text): + check_target(target, md) def main() -> None: diff --git a/scripts/test_check_links.py b/scripts/test_check_links.py new file mode 100755 index 000000000..622895dd4 --- /dev/null +++ b/scripts/test_check_links.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +"""Unit tests for scripts/check-links.py.""" +from __future__ import annotations + +import importlib.util +import sys +import tempfile +import unittest +from pathlib import Path + +SCRIPT_PATH = Path(__file__).resolve().parent / "check-links.py" + +spec = importlib.util.spec_from_file_location("check_links", SCRIPT_PATH) +mod = importlib.util.module_from_spec(spec) +sys.modules["check_links"] = mod +spec.loader.exec_module(mod) + +extract_targets = mod.extract_targets +is_external = mod.is_external +check_file = mod.check_file + + +class TestCheckLinks(unittest.TestCase): + def test_is_external(self): + self.assertTrue(is_external("https://github.com")) + self.assertTrue(is_external("http://example.com")) + self.assertTrue(is_external("mailto:user@example.com")) + self.assertTrue(is_external("tel:+1234567890")) + self.assertTrue(is_external("#section-anchor")) + self.assertTrue(is_external("custom://protocol/path")) + self.assertTrue(is_external("user@example.com")) + self.assertFalse(is_external("path/to/file.md")) + self.assertFalse(is_external("../other.md")) + + def test_table_header_autolinks(self): + doc = ( + "| | Header 2 |\n" + "| --- | --- |\n" + "| cell 1 | cell 2 |\n" + ) + targets = extract_targets(doc) + self.assertEqual(targets, ["https://github.com"]) + self.assertTrue(is_external(targets[0])) + + def test_table_header_mailto_autolinks(self): + doc = ( + "| | Contact |\n" + "| --- | --- |\n" + "| help | info |\n" + ) + targets = extract_targets(doc) + self.assertEqual(targets, ["mailto:support@example.com"]) + self.assertTrue(is_external(targets[0])) + + def test_table_body_and_multiple_autolinks(self): + doc = ( + "| | |\n" + "| --- | --- |\n" + "| | normal cell |\n" + ) + targets = extract_targets(doc) + self.assertEqual( + targets, + ["https://first.com", "https://second.org", "http://third.net"], + ) + for t in targets: + self.assertTrue(is_external(t)) + + def test_fenced_and_inline_code_stripped(self): + doc = ( + "Outside ``\n\n" + "```markdown\n" + "| | Header |\n" + "```\n\n" + "Real autolink: \n" + ) + targets = extract_targets(doc) + self.assertEqual(targets, ["https://valid.com"]) + + def test_standard_inline_links(self): + doc = "See [GitHub](https://github.com) and [Doc](relative/doc.md)." + targets = extract_targets(doc) + self.assertEqual(targets, ["https://github.com", "relative/doc.md"]) + + def test_bracketed_pointy_destinations(self): + doc = "See [Link]() and [Doc]()." + targets = extract_targets(doc) + self.assertEqual(targets, ["https://example.com", "path/to/file.md"]) + + def test_check_file_valid_and_broken_relative_links(self): + with tempfile.TemporaryDirectory() as tmpdir: + td = Path(tmpdir) + doc_path = td / "test.md" + target_path = td / "existing.md" + target_path.write_text("# Existing", encoding="utf-8") + + # Save state + prev_broken = list(mod.broken) + prev_checked = mod.checked + mod.broken.clear() + mod.checked = 0 + + try: + # Valid relative link and autolink in table header + doc_path.write_text( + "| | Header 2 |\n" + "| --- | --- |\n" + "| [Valid](existing.md) | Cell |\n", + encoding="utf-8", + ) + check_file(doc_path) + self.assertEqual(len(mod.broken), 0) + self.assertEqual(mod.checked, 1) + + # Broken relative link + doc_path.write_text( + "| | Header 2 |\n" + "| --- | --- |\n" + "| [Broken](nonexistent.md) | Cell |\n", + encoding="utf-8", + ) + check_file(doc_path) + self.assertEqual(len(mod.broken), 1) + finally: + mod.broken = prev_broken + mod.checked = prev_checked + + +if __name__ == "__main__": + unittest.main()