DEVX-118: feat: enrich lint_docs.py with single H1, max depth, line length, code block lang, orphan checks
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>
This commit is contained in:
emil
2026-07-06 10:15:54 +02:00
co-authored by Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent bb700ab969
commit fb342e7b9d
9 changed files with 342 additions and 15 deletions
+155
View File
@@ -9,11 +9,16 @@ 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,
@@ -362,6 +367,100 @@ class TestCheckDuplicateHeadings:
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."""
@@ -434,3 +533,59 @@ class TestMain:
# 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]