Public Access
Post-merge / detect-type (push) Successful in 10s
Post-merge / validate-commit-msg (push) Successful in 9s
Post-merge / vikunja (push) Successful in 19s
Post-merge / configure-repo (push) Successful in 16s
Post-merge / release (push) Successful in 40s
Post-merge / sync-wiki (push) Successful in 43s
Post-merge / publish (push) Successful in 28s
Post-merge / badges (push) Failing after 36s
- Add check_single_h1: each markdown file should have at most one H1 - Add check_max_heading_depth: headings should not exceed H4 (configurable) - Add check_line_length: warn on lines >120 chars (non-blocking — badge URLs) - Add check_code_block_languages: fenced code blocks must specify a language - Add check_orphan_docs: warn on docs not linked from index.md or mapping.json - Fix all code blocks in docs to specify language (text for plain blocks) - Fix duplicate H1 in .vale/styles/devx/README.md - Add 18 new tests for full coverage of new checks Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
592 lines
23 KiB
Python
592 lines
23 KiB
Python
"""Unit tests for devx.ci.lint_docs."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from datetime import datetime, timedelta
|
|
from pathlib import Path
|
|
|
|
from click.testing import CliRunner
|
|
|
|
from devx.ci.lint_docs import (
|
|
check_code_block_languages,
|
|
check_docs_structure,
|
|
check_duplicate_headings,
|
|
check_heading_hierarchy,
|
|
check_internal_links,
|
|
check_line_length,
|
|
check_max_heading_depth,
|
|
check_orphan_docs,
|
|
check_required_files,
|
|
check_single_h1,
|
|
check_stale_docs,
|
|
check_todo_fixme,
|
|
check_trailing_whitespace,
|
|
extract_headings,
|
|
extract_links,
|
|
main,
|
|
slugify,
|
|
strip_code_blocks,
|
|
)
|
|
|
|
|
|
class TestSlugify:
|
|
def test_basic(self) -> None:
|
|
assert slugify("Hello World") == "hello-world"
|
|
|
|
def test_special_chars(self) -> None:
|
|
assert slugify("Hello, World!") == "hello-world"
|
|
|
|
def test_multiple_spaces(self) -> None:
|
|
assert slugify("Hello World") == "hello-world"
|
|
|
|
def test_trailing_dash(self) -> None:
|
|
assert slugify("Hello World -") == "hello-world--"
|
|
|
|
def test_empty(self) -> None:
|
|
assert slugify("") == ""
|
|
|
|
|
|
class TestExtractHeadings:
|
|
def test_extracts_headings(self, tmp_path: Path) -> None:
|
|
f = tmp_path / "test.md"
|
|
f.write_text("# Title\n\n## Section\n\n### Subsection\n")
|
|
headings = extract_headings(f)
|
|
assert "title" in headings
|
|
assert headings["title"] == 1
|
|
assert "section" in headings
|
|
assert headings["section"] == 2
|
|
assert "subsection" in headings
|
|
assert headings["subsection"] == 3
|
|
|
|
def test_no_headings(self, tmp_path: Path) -> None:
|
|
f = tmp_path / "test.md"
|
|
f.write_text("Just some text.\nNo headings here.\n")
|
|
headings = extract_headings(f)
|
|
assert headings == {}
|
|
|
|
def test_ignores_headings_in_code_blocks(self, tmp_path: Path) -> None:
|
|
"""Headings inside code blocks should not be detected."""
|
|
f = tmp_path / "test.md"
|
|
f.write_text("# Title\n\n```bash\n# Not a heading\n## Also not\n```\n\n## Real Section\n")
|
|
headings = extract_headings(f)
|
|
assert "title" in headings
|
|
assert "real-section" in headings
|
|
assert "not-a-heading" not in headings
|
|
assert "also-not" not in headings
|
|
|
|
|
|
class TestStripCodeBlocks:
|
|
def test_strips_fenced_blocks(self) -> None:
|
|
content = "Before\n```bash\n# comment\n```\nAfter"
|
|
result = strip_code_blocks(content)
|
|
assert "# comment" not in result
|
|
assert "Before" in result
|
|
assert "After" in result
|
|
|
|
def test_strips_multiple_blocks(self) -> None:
|
|
content = "# Title\n```python\ncode1\n```\nText\n```yaml\ncode2\n```\nEnd"
|
|
result = strip_code_blocks(content)
|
|
assert "code1" not in result
|
|
assert "code2" not in result
|
|
assert "Text" in result
|
|
assert "End" in result
|
|
|
|
def test_no_code_blocks(self) -> None:
|
|
content = "# Title\n\nSome text."
|
|
result = strip_code_blocks(content)
|
|
assert result == content
|
|
|
|
def test_preserves_line_numbers(self) -> None:
|
|
content = "Line1\n```\nLine3\n```\nLine5"
|
|
result = strip_code_blocks(content)
|
|
lines = result.splitlines()
|
|
assert len(lines) == 5
|
|
assert lines[0] == "Line1"
|
|
assert lines[4] == "Line5"
|
|
|
|
|
|
class TestExtractLinks:
|
|
def test_extracts_internal_links(self, tmp_path: Path) -> None:
|
|
f = tmp_path / "test.md"
|
|
f.write_text("[link](other.md)\n[external](https://example.com)\n[anchor](#section)\n")
|
|
links = extract_links(f)
|
|
# Should return internal + anchor links (not http or mailto)
|
|
assert len(links) == 2
|
|
assert links[0][2] == "other.md"
|
|
assert links[1][2] == "#section"
|
|
|
|
def test_extracts_links_with_anchors(self, tmp_path: Path) -> None:
|
|
f = tmp_path / "test.md"
|
|
f.write_text("[link](other.md#section)\n")
|
|
links = extract_links(f)
|
|
assert len(links) == 1
|
|
assert links[0][2] == "other.md#section"
|
|
|
|
def test_skips_mailto(self, tmp_path: Path) -> None:
|
|
f = tmp_path / "test.md"
|
|
f.write_text("[email](mailto:test@example.com)\n")
|
|
links = extract_links(f)
|
|
assert links == []
|
|
|
|
|
|
class TestCheckRequiredFiles:
|
|
def test_all_present(self, tmp_path: Path) -> None:
|
|
(tmp_path / "README.md").write_text("# README")
|
|
(tmp_path / "AGENTS.md").write_text("# AGENTS")
|
|
(tmp_path / "CHANGELOG.md").write_text("# CHANGELOG")
|
|
issues = check_required_files(tmp_path)
|
|
assert issues == []
|
|
|
|
def test_missing_files(self, tmp_path: Path) -> None:
|
|
issues = check_required_files(tmp_path)
|
|
assert len(issues) == 3
|
|
assert any("README.md" in i for i in issues)
|
|
assert any("AGENTS.md" in i for i in issues)
|
|
assert any("CHANGELOG.md" in i for i in issues)
|
|
|
|
|
|
class TestCheckDocsStructure:
|
|
def test_all_present(self, tmp_path: Path) -> None:
|
|
docs = tmp_path / "docs"
|
|
docs.mkdir()
|
|
(docs / "index.md").write_text("# Home")
|
|
(docs / "mapping.json").write_text(json.dumps({"index.md": "Home"}))
|
|
issues = check_docs_structure(tmp_path, docs)
|
|
assert issues == []
|
|
|
|
def test_missing_docs_dir(self, tmp_path: Path) -> None:
|
|
issues = check_docs_structure(tmp_path, tmp_path / "docs")
|
|
assert len(issues) == 1
|
|
assert "Docs directory not found" in issues[0]
|
|
|
|
def test_missing_index(self, tmp_path: Path) -> None:
|
|
docs = tmp_path / "docs"
|
|
docs.mkdir()
|
|
issues = check_docs_structure(tmp_path, docs)
|
|
assert any("index.md" in i for i in issues)
|
|
|
|
def test_invalid_mapping_json(self, tmp_path: Path) -> None:
|
|
docs = tmp_path / "docs"
|
|
docs.mkdir()
|
|
(docs / "index.md").write_text("# Home")
|
|
(docs / "mapping.json").write_text("{invalid json")
|
|
issues = check_docs_structure(tmp_path, docs)
|
|
assert any("invalid JSON" in i for i in issues)
|
|
|
|
def test_empty_mapping(self, tmp_path: Path) -> None:
|
|
docs = tmp_path / "docs"
|
|
docs.mkdir()
|
|
(docs / "index.md").write_text("# Home")
|
|
(docs / "mapping.json").write_text("{}")
|
|
issues = check_docs_structure(tmp_path, docs)
|
|
assert any("empty" in i for i in issues)
|
|
|
|
def test_mapping_not_object(self, tmp_path: Path) -> None:
|
|
docs = tmp_path / "docs"
|
|
docs.mkdir()
|
|
(docs / "index.md").write_text("# Home")
|
|
(docs / "mapping.json").write_text("[]")
|
|
issues = check_docs_structure(tmp_path, docs)
|
|
assert any("JSON object" in i for i in issues)
|
|
|
|
|
|
class TestCheckInternalLinks:
|
|
def test_valid_links(self, tmp_path: Path) -> None:
|
|
(tmp_path / "README.md").write_text("[link](docs/guide.md)\n")
|
|
docs = tmp_path / "docs"
|
|
docs.mkdir()
|
|
(docs / "guide.md").write_text("# Guide\n")
|
|
issues = check_internal_links(tmp_path, docs)
|
|
assert issues == []
|
|
|
|
def test_broken_file_link(self, tmp_path: Path) -> None:
|
|
(tmp_path / "README.md").write_text("[link](nonexistent.md)\n")
|
|
issues = check_internal_links(tmp_path, tmp_path / "docs")
|
|
assert len(issues) == 1
|
|
assert "file not found" in issues[0]
|
|
|
|
def test_broken_anchor(self, tmp_path: Path) -> None:
|
|
(tmp_path / "README.md").write_text("[link](#missing-section)\n")
|
|
issues = check_internal_links(tmp_path, tmp_path / "docs")
|
|
assert len(issues) == 1
|
|
assert "broken anchor" in issues[0]
|
|
|
|
def test_broken_anchor_in_target(self, tmp_path: Path) -> None:
|
|
(tmp_path / "README.md").write_text("[link](guide.md#missing)\n")
|
|
(tmp_path / "guide.md").write_text("# Guide\n")
|
|
issues = check_internal_links(tmp_path, tmp_path / "docs")
|
|
assert len(issues) == 1
|
|
assert "broken anchor" in issues[0]
|
|
|
|
def test_valid_anchor_in_target(self, tmp_path: Path) -> None:
|
|
(tmp_path / "README.md").write_text("[link](guide.md#section)\n")
|
|
(tmp_path / "guide.md").write_text("# Section\n")
|
|
issues = check_internal_links(tmp_path, tmp_path / "docs")
|
|
assert issues == []
|
|
|
|
def test_wiki_page_link_skipped(self, tmp_path: Path) -> None:
|
|
"""Links matching wiki page names in mapping.json should be skipped."""
|
|
docs = tmp_path / "docs"
|
|
docs.mkdir()
|
|
(docs / "index.md").write_text("[Architecture](Architecture)\n")
|
|
(docs / "mapping.json").write_text(json.dumps({"index.md": "Home", "tech/architecture.md": "Architecture"}))
|
|
issues = check_internal_links(tmp_path, docs)
|
|
assert issues == []
|
|
|
|
def test_non_wiki_page_no_extension_skipped(self, tmp_path: Path) -> None:
|
|
"""Links without file extension and no slash should be skipped (can't verify)."""
|
|
(tmp_path / "README.md").write_text("[SomePage](SomePage)\n")
|
|
issues = check_internal_links(tmp_path, tmp_path / "docs")
|
|
assert issues == []
|
|
|
|
def test_broken_anchor_in_target_with_content(self, tmp_path: Path) -> None:
|
|
"""Broken anchor in an existing target file should be flagged."""
|
|
(tmp_path / "README.md").write_text("[link](guide.md#missing)\n")
|
|
(tmp_path / "guide.md").write_text("# Real Title\n\nSome content here.\n")
|
|
issues = check_internal_links(tmp_path, tmp_path / "docs")
|
|
assert len(issues) == 1
|
|
assert "broken anchor" in issues[0]
|
|
|
|
def test_valid_anchor_in_target_with_content(self, tmp_path: Path) -> None:
|
|
"""Valid anchor in an existing target file should pass."""
|
|
(tmp_path / "README.md").write_text("[link](guide.md#real-title)\n")
|
|
(tmp_path / "guide.md").write_text("# Real Title\n\nSome content.\n")
|
|
issues = check_internal_links(tmp_path, tmp_path / "docs")
|
|
assert issues == []
|
|
|
|
def test_invalid_mapping_json_ignored(self, tmp_path: Path) -> None:
|
|
"""Invalid mapping.json should not crash link checking."""
|
|
docs = tmp_path / "docs"
|
|
docs.mkdir()
|
|
(docs / "index.md").write_text("[link](guide.md)\n")
|
|
(docs / "guide.md").write_text("# Guide\n")
|
|
(docs / "mapping.json").write_text("{invalid json")
|
|
issues = check_internal_links(tmp_path, docs)
|
|
# Should still work — just without wiki page mappings
|
|
assert issues == []
|
|
|
|
|
|
class TestCheckHeadingHierarchy:
|
|
def test_valid_hierarchy(self, tmp_path: Path) -> None:
|
|
(tmp_path / "README.md").write_text("# Title\n## Section\n### Sub\n")
|
|
issues = check_heading_hierarchy(tmp_path)
|
|
assert issues == []
|
|
|
|
def test_skipped_level(self, tmp_path: Path) -> None:
|
|
(tmp_path / "README.md").write_text("# Title\n### Sub\n")
|
|
issues = check_heading_hierarchy(tmp_path)
|
|
assert len(issues) == 1
|
|
assert "hierarchy skip" in issues[0]
|
|
|
|
|
|
class TestCheckTodoFixme:
|
|
def test_no_todo(self, tmp_path: Path) -> None:
|
|
(tmp_path / "README.md").write_text("Just some text.\n")
|
|
issues = check_todo_fixme(tmp_path)
|
|
assert issues == []
|
|
|
|
def test_found_todo(self, tmp_path: Path) -> None:
|
|
(tmp_path / "README.md").write_text("TODO: fix this later\n")
|
|
issues = check_todo_fixme(tmp_path)
|
|
assert len(issues) == 1
|
|
assert "TODO" in issues[0]
|
|
|
|
def test_found_fixme(self, tmp_path: Path) -> None:
|
|
(tmp_path / "README.md").write_text("FIXME: broken code\n")
|
|
issues = check_todo_fixme(tmp_path)
|
|
assert len(issues) == 1
|
|
assert "FIXME" in issues[0]
|
|
|
|
def test_ignores_todo_in_rules(self, tmp_path: Path) -> None:
|
|
"""References to 'TODO' in rules docs should not be flagged."""
|
|
(tmp_path / "README.md").write_text("Best practices (no `print()`, no `TODO`/`FIXME`)\n")
|
|
issues = check_todo_fixme(tmp_path)
|
|
assert issues == []
|
|
|
|
def test_ignores_todo_without_colon(self, tmp_path: Path) -> None:
|
|
"""'TODO' without a colon should not be flagged."""
|
|
(tmp_path / "README.md").write_text("The TODO list is empty\n")
|
|
issues = check_todo_fixme(tmp_path)
|
|
assert issues == []
|
|
|
|
|
|
class TestCheckTrailingWhitespace:
|
|
def test_no_trailing(self, tmp_path: Path) -> None:
|
|
(tmp_path / "README.md").write_text("No trailing whitespace here\n")
|
|
issues = check_trailing_whitespace(tmp_path)
|
|
assert issues == []
|
|
|
|
def test_trailing_spaces(self, tmp_path: Path) -> None:
|
|
(tmp_path / "README.md").write_text("Trailing spaces \n")
|
|
issues = check_trailing_whitespace(tmp_path)
|
|
assert len(issues) == 1
|
|
assert "trailing whitespace" in issues[0]
|
|
|
|
def test_trailing_tabs(self, tmp_path: Path) -> None:
|
|
(tmp_path / "README.md").write_text("Trailing tabs\t\n")
|
|
issues = check_trailing_whitespace(tmp_path)
|
|
assert len(issues) == 1
|
|
|
|
|
|
class TestCheckStaleDocs:
|
|
def test_fresh_doc(self, tmp_path: Path) -> None:
|
|
(tmp_path / "README.md").write_text("Fresh content\n")
|
|
issues = check_stale_docs(tmp_path)
|
|
assert issues == []
|
|
|
|
def test_stale_doc(self, tmp_path: Path) -> None:
|
|
f = tmp_path / "README.md"
|
|
f.write_text("Old content\n")
|
|
# Set mtime to 200 days ago
|
|
old_time = (datetime.now() - timedelta(days=200)).timestamp()
|
|
import os
|
|
|
|
os.utime(f, (old_time, old_time))
|
|
issues = check_stale_docs(tmp_path)
|
|
assert len(issues) == 1
|
|
assert "stale" in issues[0]
|
|
|
|
|
|
class TestCheckDuplicateHeadings:
|
|
def test_no_duplicates(self, tmp_path: Path) -> None:
|
|
(tmp_path / "README.md").write_text("# Title\n## Section\n")
|
|
issues = check_duplicate_headings(tmp_path)
|
|
assert issues == []
|
|
|
|
def test_duplicates(self, tmp_path: Path) -> None:
|
|
(tmp_path / "README.md").write_text("# Title\n# Title\n")
|
|
issues = check_duplicate_headings(tmp_path)
|
|
assert len(issues) == 1
|
|
assert "duplicate heading" in issues[0]
|
|
|
|
def test_changelog_excluded(self, tmp_path: Path) -> None:
|
|
"""CHANGELOG.md should be excluded from duplicate heading checks."""
|
|
(tmp_path / "CHANGELOG.md").write_text("# Features\n# Features\n# Features\n")
|
|
issues = check_duplicate_headings(tmp_path)
|
|
assert issues == []
|
|
|
|
|
|
class TestCheckSingleH1:
|
|
def test_single_h1_ok(self, tmp_path: Path) -> None:
|
|
(tmp_path / "README.md").write_text("# Title\n## Section\n")
|
|
issues = check_single_h1(tmp_path)
|
|
assert issues == []
|
|
|
|
def test_multiple_h1_fails(self, tmp_path: Path) -> None:
|
|
(tmp_path / "README.md").write_text("# Title 1\n# Title 2\n")
|
|
issues = check_single_h1(tmp_path)
|
|
assert len(issues) == 1
|
|
assert "2 H1" in issues[0]
|
|
|
|
def test_no_h1_ok(self, tmp_path: Path) -> None:
|
|
(tmp_path / "README.md").write_text("## Section\n")
|
|
issues = check_single_h1(tmp_path)
|
|
assert issues == []
|
|
|
|
|
|
class TestCheckMaxHeadingDepth:
|
|
def test_ok(self, tmp_path: Path) -> None:
|
|
(tmp_path / "README.md").write_text("# H1\n## H2\n### H3\n#### H4\n")
|
|
issues = check_max_heading_depth(tmp_path)
|
|
assert issues == []
|
|
|
|
def test_too_deep(self, tmp_path: Path) -> None:
|
|
(tmp_path / "README.md").write_text("# H1\n##### H5\n")
|
|
issues = check_max_heading_depth(tmp_path)
|
|
assert len(issues) == 1
|
|
assert "H5" in issues[0]
|
|
|
|
|
|
class TestCheckLineLength:
|
|
def test_ok(self, tmp_path: Path) -> None:
|
|
(tmp_path / "README.md").write_text("# Short line\n")
|
|
issues = check_line_length(tmp_path)
|
|
assert issues == []
|
|
|
|
def test_too_long(self, tmp_path: Path) -> None:
|
|
(tmp_path / "README.md").write_text("# " + "x" * 200 + "\n")
|
|
issues = check_line_length(tmp_path)
|
|
assert len(issues) == 1
|
|
assert "202" in issues[0]
|
|
|
|
|
|
class TestCheckCodeBlockLanguages:
|
|
def test_with_language(self, tmp_path: Path) -> None:
|
|
(tmp_path / "README.md").write_text("```python\nprint('hi')\n```\n")
|
|
issues = check_code_block_languages(tmp_path)
|
|
assert issues == []
|
|
|
|
def test_without_language(self, tmp_path: Path) -> None:
|
|
(tmp_path / "README.md").write_text("```\nplain text\n```\n")
|
|
issues = check_code_block_languages(tmp_path)
|
|
assert len(issues) == 1
|
|
assert "without language" in issues[0]
|
|
|
|
def test_closing_fence_not_flagged(self, tmp_path: Path) -> None:
|
|
(tmp_path / "README.md").write_text("```python\nprint('hi')\n```\n")
|
|
issues = check_code_block_languages(tmp_path)
|
|
assert issues == []
|
|
|
|
|
|
class TestCheckOrphanDocs:
|
|
def test_no_orphans(self, tmp_path: Path) -> None:
|
|
docs = tmp_path / "docs"
|
|
docs.mkdir()
|
|
(docs / "index.md").write_text("# Home\n[link](page.md)\n")
|
|
(docs / "page.md").write_text("# Page\n")
|
|
issues = check_orphan_docs(tmp_path, docs)
|
|
assert issues == []
|
|
|
|
def test_orphan_found(self, tmp_path: Path) -> None:
|
|
docs = tmp_path / "docs"
|
|
docs.mkdir()
|
|
(docs / "index.md").write_text("# Home\n")
|
|
(docs / "page.md").write_text("# Page\n")
|
|
issues = check_orphan_docs(tmp_path, docs)
|
|
assert len(issues) == 1
|
|
assert "orphan" in issues[0]
|
|
|
|
def test_no_docs_dir(self, tmp_path: Path) -> None:
|
|
issues = check_orphan_docs(tmp_path, tmp_path / "docs")
|
|
assert issues == []
|
|
|
|
def test_referenced_in_mapping(self, tmp_path: Path) -> None:
|
|
docs = tmp_path / "docs"
|
|
docs.mkdir()
|
|
(docs / "index.md").write_text("# Home\n")
|
|
(docs / "mapping.json").write_text(json.dumps({"page.md": "Page"}))
|
|
(docs / "page.md").write_text("# Page\n")
|
|
issues = check_orphan_docs(tmp_path, docs)
|
|
assert issues == []
|
|
|
|
|
|
class TestMain:
|
|
def test_passes_clean_repo(self, tmp_path: Path) -> None:
|
|
"""A clean repo with all files should pass."""
|
|
(tmp_path / "README.md").write_text("# Title\n\nContent here.\n")
|
|
(tmp_path / "AGENTS.md").write_text("# AGENTS\n\nContent here.\n")
|
|
(tmp_path / "CHANGELOG.md").write_text("# Changelog\n\nContent here.\n")
|
|
docs = tmp_path / "docs"
|
|
docs.mkdir()
|
|
(docs / "index.md").write_text("# Home\n")
|
|
(docs / "mapping.json").write_text(json.dumps({"index.md": "Home"}))
|
|
runner = CliRunner()
|
|
result = runner.invoke(main, ["--root", str(tmp_path)])
|
|
assert result.exit_code == 0
|
|
assert "PASS" in result.output
|
|
|
|
def test_fails_on_missing_files(self, tmp_path: Path) -> None:
|
|
"""Missing required files should fail."""
|
|
runner = CliRunner()
|
|
result = runner.invoke(main, ["--root", str(tmp_path)])
|
|
assert result.exit_code == 1
|
|
assert "FAIL" in result.output
|
|
|
|
def test_fix_trailing_whitespace(self, tmp_path: Path) -> None:
|
|
"""--fix should auto-fix trailing whitespace."""
|
|
(tmp_path / "README.md").write_text("# Title\n\nContent here. \n")
|
|
(tmp_path / "AGENTS.md").write_text("# AGENTS\n\nContent here.\n")
|
|
(tmp_path / "CHANGELOG.md").write_text("# Changelog\n\nContent here.\n")
|
|
docs = tmp_path / "docs"
|
|
docs.mkdir()
|
|
(docs / "index.md").write_text("# Home\n")
|
|
(docs / "mapping.json").write_text(json.dumps({"index.md": "Home"}))
|
|
runner = CliRunner()
|
|
result = runner.invoke(main, ["--root", str(tmp_path), "--fix"])
|
|
assert result.exit_code == 0
|
|
# Verify whitespace was fixed
|
|
content = (tmp_path / "README.md").read_text()
|
|
assert "Content here. \n" not in content
|
|
assert "Content here.\n" in content
|
|
|
|
def test_no_check_links(self, tmp_path: Path) -> None:
|
|
"""--no-check-links should skip link checking."""
|
|
(tmp_path / "README.md").write_text("# Title\n[broken](nonexistent.md)\n")
|
|
(tmp_path / "AGENTS.md").write_text("# AGENTS\n")
|
|
(tmp_path / "CHANGELOG.md").write_text("# Changelog\n")
|
|
docs = tmp_path / "docs"
|
|
docs.mkdir()
|
|
(docs / "index.md").write_text("# Home\n")
|
|
(docs / "mapping.json").write_text(json.dumps({"index.md": "Home"}))
|
|
runner = CliRunner()
|
|
result = runner.invoke(main, ["--root", str(tmp_path), "--no-check-links"])
|
|
assert result.exit_code == 0
|
|
|
|
def test_stale_docs_warning(self, tmp_path: Path) -> None:
|
|
"""--check-stale should warn but not fail."""
|
|
(tmp_path / "README.md").write_text("# Title\n")
|
|
(tmp_path / "AGENTS.md").write_text("# AGENTS\n")
|
|
(tmp_path / "CHANGELOG.md").write_text("# Changelog\n")
|
|
docs = tmp_path / "docs"
|
|
docs.mkdir()
|
|
(docs / "index.md").write_text("# Home\n")
|
|
(docs / "mapping.json").write_text(json.dumps({"index.md": "Home"}))
|
|
# Make README stale
|
|
import os
|
|
|
|
f = tmp_path / "README.md"
|
|
old_time = (datetime.now() - timedelta(days=200)).timestamp()
|
|
os.utime(f, (old_time, old_time))
|
|
runner = CliRunner()
|
|
result = runner.invoke(main, ["--root", str(tmp_path), "--check-stale"])
|
|
# Stale docs are warnings, not errors
|
|
assert result.exit_code == 0
|
|
assert "stale" in result.output
|
|
|
|
def test_line_length_warning(self, tmp_path: Path) -> None:
|
|
"""--check-line-length should warn but not fail."""
|
|
(tmp_path / "README.md").write_text("# " + "x" * 200 + "\n")
|
|
(tmp_path / "AGENTS.md").write_text("# AGENTS\n")
|
|
(tmp_path / "CHANGELOG.md").write_text("# Changelog\n")
|
|
docs = tmp_path / "docs"
|
|
docs.mkdir()
|
|
(docs / "index.md").write_text("# Home\n")
|
|
(docs / "mapping.json").write_text(json.dumps({"index.md": "Home"}))
|
|
runner = CliRunner()
|
|
result = runner.invoke(main, ["--root", str(tmp_path), "--check-line-length"])
|
|
assert result.exit_code == 0
|
|
assert "long lines" in result.output
|
|
|
|
def test_line_length_many_warnings(self, tmp_path: Path) -> None:
|
|
"""More than 10 long lines should show '... and N more'."""
|
|
long_line = "x" * 200 + "\n"
|
|
(tmp_path / "README.md").write_text(long_line * 15)
|
|
(tmp_path / "AGENTS.md").write_text("# AGENTS\n")
|
|
(tmp_path / "CHANGELOG.md").write_text("# Changelog\n")
|
|
docs = tmp_path / "docs"
|
|
docs.mkdir()
|
|
(docs / "index.md").write_text("# Home\n")
|
|
(docs / "mapping.json").write_text(json.dumps({"index.md": "Home"}))
|
|
runner = CliRunner()
|
|
result = runner.invoke(main, ["--root", str(tmp_path), "--check-line-length"])
|
|
assert result.exit_code == 0
|
|
assert "more" in result.output
|
|
|
|
def test_orphan_docs_warning(self, tmp_path: Path) -> None:
|
|
"""--check-orphans should warn but not fail."""
|
|
(tmp_path / "README.md").write_text("# Title\n")
|
|
(tmp_path / "AGENTS.md").write_text("# AGENTS\n")
|
|
(tmp_path / "CHANGELOG.md").write_text("# Changelog\n")
|
|
docs = tmp_path / "docs"
|
|
docs.mkdir()
|
|
(docs / "index.md").write_text("# Home\n")
|
|
(docs / "mapping.json").write_text(json.dumps({"index.md": "Home"}))
|
|
(docs / "orphan.md").write_text("# Orphan\n")
|
|
runner = CliRunner()
|
|
result = runner.invoke(main, ["--root", str(tmp_path), "--check-orphans"])
|
|
assert result.exit_code == 0
|
|
assert "orphan" in result.output
|
|
|
|
def test_orphan_docs_invalid_mapping(self, tmp_path: Path) -> None:
|
|
"""Invalid mapping.json should not crash orphan check."""
|
|
docs = tmp_path / "docs"
|
|
docs.mkdir()
|
|
(docs / "index.md").write_text("# Home\n")
|
|
(docs / "mapping.json").write_text("invalid json{")
|
|
(docs / "page.md").write_text("# Page\n")
|
|
# Should not raise — just returns issues
|
|
issues = check_orphan_docs(tmp_path, docs)
|
|
assert len(issues) == 1
|
|
assert "orphan" in issues[0]
|