diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b5c68e4..9386888 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 87523f9..1a17ccf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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`. diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..a93f506 --- /dev/null +++ b/requirements-dev.txt @@ -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 diff --git a/scripts/tests/test_validate_docs.py b/scripts/tests/test_validate_docs.py new file mode 100644 index 0000000..a4ad93a --- /dev/null +++ b/scripts/tests/test_validate_docs.py @@ -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]()\n" + "[Reference guide][guide]\n\n" + "[guide]: \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() diff --git a/scripts/validate_docs.py b/scripts/validate_docs.py new file mode 100644 index 0000000..e21e0ae --- /dev/null +++ b/scripts/validate_docs.py @@ -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())