Files
devx/tests/unit/test_lint_docs.py
T
emilandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> fb342e7b9d
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
DEVX-118: feat: enrich lint_docs.py with single H1, max depth, line length, code block lang, orphan checks
- 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>
2026-07-06 10:15:54 +02:00

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]