Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
63 changes: 46 additions & 17 deletions scripts/check-links.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"`[^`]*`")
Expand All @@ -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. <owner>/<repo>
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](<url>)`
# 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. <owner>/<repo>
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:
Expand Down
130 changes: 130 additions & 0 deletions scripts/test_check_links.py
Original file line number Diff line number Diff line change
@@ -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 = (
"| <https://github.com> | 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 = (
"| <mailto:support@example.com> | 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 = (
"| <https://first.com> | <https://second.org> |\n"
"| --- | --- |\n"
"| <http://third.net> | 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 `<https://inline-code.com>`\n\n"
"```markdown\n"
"| <https://fenced-code.com> | Header |\n"
"```\n\n"
"Real autolink: <https://valid.com>\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](<https://example.com>) and [Doc](<path/to/file.md>)."
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(
"| <https://github.com> | 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(
"| <https://github.com> | 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()
Loading