Skip to content
Open
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/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,7 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- run: python -m pip install -r requirements-dev.txt
- run: python -m compileall -q scripts skills
- run: PYTHONPATH=scripts python -m unittest discover -s scripts/tests -p 'test_*.py' -v
- run: PYTHONPATH=scripts python scripts/validate_docs.py
2 changes: 2 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@ Open an issue before adding a new live write path, private/undocumented API, or
```bash
git clone https://github.com/Chere3/hermes-automation-stack.git
cd hermes-automation-stack
python -m pip install -r requirements-dev.txt
python -m compileall -q scripts skills
PYTHONPATH=scripts python -m unittest discover -s scripts/tests -p 'test_*.py' -v
PYTHONPATH=scripts python scripts/validate_docs.py
```

Optional dependencies can be installed in a virtual environment from `requirements.txt`.
Expand Down
3 changes: 3 additions & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Used only by repository validation and its tests; collectors do not import it.
markdown-it-py>=3.0
PyYAML>=6.0
111 changes: 111 additions & 0 deletions scripts/tests/test_validate_docs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import io
import tempfile
import unittest
from contextlib import redirect_stderr
from pathlib import Path
from unittest.mock import patch

from validate_docs import main, markdown_files, validate_repository


class DocumentationValidationTests(unittest.TestCase):
def write(self, root: Path, relative_path: str, content: str) -> None:
path = root / relative_path
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")

def test_reports_a_missing_relative_markdown_link_but_ignores_code_samples(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
self.write(
root,
"README.md",
"[Missing](docs/missing.md)\n\n"
"`[inline](not-a-link.md)`\n\n"
"```md\n[example](also-not-a-link.md)\n```\n",
)
errors = validate_repository(root)
self.assertIn("README.md: missing relative link: docs/missing.md", errors)
self.assertEqual(len(errors), 1)

def test_parses_spaces_parentheses_and_external_schemes(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
self.write(root, "docs/guide (v1).md", "# Guide\n")
self.write(
root,
"README.md",
"[Guide](<docs/guide (v1).md>)\n"
"[Reference guide][guide]\n\n"
"[guide]: <docs/guide (v1).md>\n\n"
"[Website](https://example.test/docs)\n"
"[FTP](ftp://example.test/archive)\n"
"[Anchor](#section)\n"
"[CDN](//example.test/image.png)\n",
)
errors = validate_repository(root)
self.assertEqual(errors, [])

def test_covers_root_readmes_docs_and_skills(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
self.write(root, "README.md", "# Readme\n")
self.write(root, "docs/guide.md", "# Guide\n")
self.write(root, "skills/example/README.md", "# Example\n")
files = {path.relative_to(root).as_posix() for path in markdown_files(root)}
self.assertEqual(files, {"README.md", "docs/guide.md", "skills/example/README.md"})

def test_reports_malformed_skill_frontmatter(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
self.write(root, "README.md", "# Readme\n")
self.write(root, "skills/example/SKILL.md", "---\nname: [broken\n---\n")
errors = validate_repository(root)
self.assertTrue(any("skills/example/SKILL.md: malformed YAML frontmatter" in error for error in errors))

def test_reports_missing_skill_frontmatter(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
self.write(root, "README.md", "# Readme\n")
self.write(root, "skills/example/SKILL.md", "# No metadata\n")
errors = validate_repository(root)
self.assertIn(
"skills/example/SKILL.md: missing opening YAML frontmatter delimiter",
errors,
)

def test_reports_duplicate_frontmatter_keys(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
self.write(root, "README.md", "# Readme\n")
self.write(
root,
"skills/example/SKILL.md",
"---\nname: first\nname: second\n---\n",
)
errors = validate_repository(root)
self.assertTrue(any("found duplicate key 'name'" in error for error in errors))

def test_reports_unhashable_frontmatter_keys(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
self.write(root, "README.md", "# Readme\n")
self.write(root, "skills/example/SKILL.md", "---\n[one, two]: value\n---\n")
errors = validate_repository(root)
self.assertTrue(any("skills/example/SKILL.md: malformed YAML frontmatter" in error for error in errors))

def test_current_repository_content_passes(self):
self.assertEqual(validate_repository(), [])

def test_cli_prints_failures_to_stderr_and_returns_nonzero(self):
stderr = io.StringIO()
with patch("validate_docs.validate_repository", return_value=["README.md: missing relative link: docs/nope.md"]):
with redirect_stderr(stderr):
result = main()
self.assertEqual(result, 1)
self.assertIn("Documentation validation failed:", stderr.getvalue())
self.assertIn("README.md: missing relative link: docs/nope.md", stderr.getvalue())


if __name__ == "__main__":
unittest.main()
151 changes: 151 additions & 0 deletions scripts/validate_docs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
#!/usr/bin/env python3
"""Validate local Markdown links and YAML metadata in reusable skills.

The check is deliberately offline: external URLs are outside this repository's
control, while broken local links and malformed skill metadata are regressions
we can catch deterministically in CI.
"""

from __future__ import annotations

import sys
from pathlib import Path
from urllib.parse import unquote, urlsplit

from markdown_it import MarkdownIt
import yaml


PROJECT_ROOT = Path(__file__).resolve().parents[1]
MARKDOWN = MarkdownIt("commonmark")


class UniqueKeyLoader(yaml.SafeLoader):
"""Safe YAML loader that refuses ambiguous duplicate mapping keys."""


def construct_unique_mapping(loader: UniqueKeyLoader, node: yaml.nodes.MappingNode, deep: bool = False):
mapping = {}
for key_node, value_node in node.value:
key = loader.construct_object(key_node, deep=deep)
if key in mapping:
raise yaml.constructor.ConstructorError(
"while constructing a mapping",
node.start_mark,
f"found duplicate key {key!r}",
key_node.start_mark,
)
mapping[key] = loader.construct_object(value_node, deep=deep)
return mapping


UniqueKeyLoader.add_constructor(
yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, construct_unique_mapping
)


def markdown_files(root: Path) -> list[Path]:
"""Return Markdown files covered by the repository link check."""
files = set(root.glob("README*.md"))
files.update((root / "docs").rglob("*.md"))
files.update((root / "skills").rglob("*.md"))
return sorted(path for path in files if path.is_file())


def is_local_target(target: str | None) -> bool:
if not target:
return False
parsed = urlsplit(target)
return not (
target.startswith("#")
or target.startswith("//")
or bool(parsed.scheme)
)


def markdown_destinations(text: str) -> list[str]:
"""Return link and image destinations parsed from Markdown, not code samples."""
destinations: list[str] = []
for token in MARKDOWN.parse(text):
inline_tokens = token.children or []
for inline_token in inline_tokens:
if inline_token.type == "link_open":
destination = inline_token.attrGet("href")
elif inline_token.type == "image":
destination = inline_token.attrGet("src")
else:
continue
if destination is not None:
destinations.append(destination)
return destinations


def validate_markdown_links(path: Path, root: Path) -> list[str]:
"""Return failures for relative Markdown destinations in *path*."""
text = path.read_text(encoding="utf-8")
failures: list[str] = []
relative_path = path.relative_to(root).as_posix()

for target in markdown_destinations(text):
if not is_local_target(target):
continue
parsed = urlsplit(target)
destination = unquote(parsed.path)
if not destination:
continue
resolved = (path.parent / destination).resolve()
try:
resolved.relative_to(root.resolve())
except ValueError:
failures.append(f"{relative_path}: relative link escapes repository: {target}")
else:
if not resolved.exists():
failures.append(f"{relative_path}: missing relative link: {target}")
return failures


def validate_skill_frontmatter(path: Path, root: Path) -> list[str]:
"""Return failures for the YAML frontmatter at the start of a SKILL.md."""
text = path.read_text(encoding="utf-8")
relative_path = path.relative_to(root).as_posix()
if not text.startswith("---\n"):
return [f"{relative_path}: missing opening YAML frontmatter delimiter"]

closing_delimiter = text.find("\n---\n", len("---\n"))
if closing_delimiter == -1:
return [f"{relative_path}: missing closing YAML frontmatter delimiter"]

frontmatter = text[len("---\n") : closing_delimiter]
try:
metadata = yaml.load(frontmatter, Loader=UniqueKeyLoader)
except (TypeError, yaml.YAMLError) as error:
return [
f"{relative_path}: malformed YAML frontmatter: {getattr(error, 'problem', None) or error}"
]
if not isinstance(metadata, dict):
return [f"{relative_path}: YAML frontmatter must be a mapping"]
return []


def validate_repository(root: Path = PROJECT_ROOT) -> list[str]:
"""Validate all repository-owned Markdown and skill metadata."""
errors: list[str] = []
for path in markdown_files(root):
errors.extend(validate_markdown_links(path, root))
for path in sorted((root / "skills").rglob("SKILL.md")):
errors.extend(validate_skill_frontmatter(path, root))
return errors


def main() -> int:
errors = validate_repository()
if errors:
print("Documentation validation failed:", file=sys.stderr)
print(*errors, sep="\n", file=sys.stderr)
return 1
print("Documentation validation passed.")
return 0


if __name__ == "__main__":
raise SystemExit(main())