Public Access
DEVX-98: feat: add lint_docs tool, fix doc_coverage/check_translations for any repo
Post-merge / detect-type (push) Successful in 10s
Post-merge / validate-commit-msg (push) Successful in 8s
Post-merge / vikunja (push) Successful in 13s
Post-merge / configure-repo (push) Successful in 11s
Post-merge / sync-wiki (push) Successful in 21s
Post-merge / release (push) Successful in 25s
Build Images / detect-type (push) Successful in 38s
Post-merge / badges (push) Successful in 34s
Post-merge / publish (push) Successful in 33s
Build Images / build-and-push (push) Successful in 3m20s
Build Images / cleanup (push) Successful in 1m57s
Post-merge / detect-type (push) Successful in 10s
Post-merge / validate-commit-msg (push) Successful in 8s
Post-merge / vikunja (push) Successful in 13s
Post-merge / configure-repo (push) Successful in 11s
Post-merge / sync-wiki (push) Successful in 21s
Post-merge / release (push) Successful in 25s
Build Images / detect-type (push) Successful in 38s
Post-merge / badges (push) Successful in 34s
Post-merge / publish (push) Successful in 33s
Build Images / build-and-push (push) Successful in 3m20s
Build Images / cleanup (push) Successful in 1m57s
This commit was merged in pull request #154.
This commit is contained in:
@@ -184,6 +184,14 @@ class TestMain:
|
||||
result = runner.invoke(check_translations.main, ["--translations", str(trans_file)])
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_no_translations_file_skips(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""When no translations file is found, should pass with skip message."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(check_translations.main, [])
|
||||
assert result.exit_code == 0
|
||||
assert "No translations file found" in result.output
|
||||
|
||||
|
||||
class TestPrintResult:
|
||||
def test_prints_all_good(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
|
||||
@@ -87,6 +87,13 @@ class TestCiCommands:
|
||||
assert result.exit_code == 0
|
||||
mock_run.assert_called_once_with("devx.ci.doc_coverage", [])
|
||||
|
||||
@patch("devx.cli._run_module")
|
||||
def test_ci_lint_docs(self, mock_run: MagicMock) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["ci", "lint-docs", "--", "--root", "."])
|
||||
assert result.exit_code == 0
|
||||
mock_run.assert_called_once_with("devx.ci.lint_docs", ["--root", "."])
|
||||
|
||||
@patch("devx.cli._run_module")
|
||||
def test_ci_notify_failure(self, mock_run: MagicMock) -> None:
|
||||
runner = CliRunner()
|
||||
|
||||
@@ -12,10 +12,13 @@ from devx.ci.doc_coverage import (
|
||||
main,
|
||||
)
|
||||
|
||||
# Path to devx's own source directory (for testing)
|
||||
DEVX_SRC_DIR = Path(__file__).resolve().parent.parent.parent / "src" / "devx"
|
||||
|
||||
|
||||
class TestExtractCliCommands:
|
||||
def test_extracts_commands(self) -> None:
|
||||
commands = extract_cli_commands()
|
||||
commands = extract_cli_commands(DEVX_SRC_DIR)
|
||||
# devx CLI has commands under ci, tools, and molecule groups
|
||||
assert "auto-merge" in commands
|
||||
assert "release" in commands
|
||||
@@ -24,39 +27,30 @@ class TestExtractCliCommands:
|
||||
assert "install-tools" in commands
|
||||
|
||||
def test_returns_list(self) -> None:
|
||||
commands = extract_cli_commands()
|
||||
commands = extract_cli_commands(DEVX_SRC_DIR)
|
||||
assert isinstance(commands, list)
|
||||
assert len(commands) > 0
|
||||
|
||||
def test_no_cli_file(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_no_cli_file(self, tmp_path: Path) -> None:
|
||||
"""Returns empty list when CLI file doesn't exist."""
|
||||
from devx.ci import doc_coverage
|
||||
|
||||
monkeypatch.setattr(doc_coverage, "CLI_FILE", Path("/nonexistent/cli.py"))
|
||||
commands = extract_cli_commands()
|
||||
commands = extract_cli_commands(tmp_path)
|
||||
assert commands == []
|
||||
|
||||
def test_def_fallback_no_explicit_name(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_def_fallback_no_explicit_name(self, tmp_path: Path) -> None:
|
||||
"""When a command decorator has no explicit name, falls back to the def name."""
|
||||
from devx.ci import doc_coverage
|
||||
|
||||
fake_cli = tmp_path / "cli.py"
|
||||
fake_cli.write_text("@click.group()\ndef cli():\n pass\n@cli.command()\ndef my_command():\n pass\n")
|
||||
monkeypatch.setattr(doc_coverage, "CLI_FILE", fake_cli)
|
||||
commands = extract_cli_commands()
|
||||
commands = extract_cli_commands(tmp_path)
|
||||
assert "my_command" in commands
|
||||
|
||||
def test_command_decorator_no_def_fallback(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_command_decorator_no_def_fallback(self, tmp_path: Path) -> None:
|
||||
"""When a command decorator has no name and no following def, it is skipped."""
|
||||
from devx.ci import doc_coverage
|
||||
|
||||
fake_cli = tmp_path / "cli.py"
|
||||
# The last @cli.command() has no explicit name and no def statement after it
|
||||
fake_cli.write_text(
|
||||
"@click.group()\ndef cli():\n pass\n@cli.command()\ndef real_cmd():\n pass\n@cli.command()\npass\n"
|
||||
)
|
||||
monkeypatch.setattr(doc_coverage, "CLI_FILE", fake_cli)
|
||||
commands = extract_cli_commands()
|
||||
commands = extract_cli_commands(tmp_path)
|
||||
# real_cmd should be found via def fallback; the bare @cli.command() is skipped
|
||||
assert "real_cmd" in commands
|
||||
assert "pass" not in commands
|
||||
@@ -96,19 +90,29 @@ class TestMain:
|
||||
docs = tmp_path / "docs"
|
||||
(docs / "user").mkdir(parents=True)
|
||||
(docs / "tech").mkdir(parents=True)
|
||||
# Get actual commands from the CLI
|
||||
commands = extract_cli_commands()
|
||||
src = tmp_path / "src" / "devx"
|
||||
src.mkdir(parents=True)
|
||||
(src / "ci").mkdir()
|
||||
(src / "__init__.py").write_text("")
|
||||
(src / "ci" / "__init__.py").write_text("")
|
||||
# Create a fake cli.py with some commands
|
||||
(src / "cli.py").write_text(
|
||||
"@click.group()\ndef cli():\n pass\n"
|
||||
"@cli.command('release')\ndef release():\n pass\n"
|
||||
"@cli.command('setup')\ndef setup():\n pass\n"
|
||||
)
|
||||
# Create a fake module and CI script
|
||||
(src / "config.py").write_text("# config module")
|
||||
(src / "ci" / "auto_merge.py").write_text("# auto_merge script")
|
||||
# Write cli-commands.md with all commands
|
||||
cli_content = "\n".join(f"## {cmd}" for cmd in commands)
|
||||
cli_content = "## release\n\n## setup\n"
|
||||
(docs / "user" / "cli-commands.md").write_text(cli_content)
|
||||
# Write architecture.md with all modules
|
||||
from devx.ci.doc_coverage import REQUIRED_MODULES, REQUIRED_SCRIPTS
|
||||
|
||||
(docs / "tech" / "architecture.md").write_text(" ".join(REQUIRED_MODULES))
|
||||
(docs / "tech" / "architecture.md").write_text("config.py")
|
||||
# Write ci-cd-workflow.md with all scripts
|
||||
(docs / "tech" / "ci-cd-workflow.md").write_text(" ".join(REQUIRED_SCRIPTS))
|
||||
(docs / "tech" / "ci-cd-workflow.md").write_text("auto_merge.py")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--docs-dir", str(docs)])
|
||||
result = runner.invoke(main, ["--docs-dir", str(docs), "--source-dir", str(src)])
|
||||
assert result.exit_code == 0
|
||||
assert "100%" in result.output
|
||||
|
||||
@@ -117,11 +121,21 @@ class TestMain:
|
||||
docs = tmp_path / "docs"
|
||||
(docs / "user").mkdir(parents=True)
|
||||
(docs / "tech").mkdir(parents=True)
|
||||
src = tmp_path / "src" / "devx"
|
||||
src.mkdir(parents=True)
|
||||
(src / "ci").mkdir()
|
||||
(src / "__init__.py").write_text("")
|
||||
(src / "ci" / "__init__.py").write_text("")
|
||||
(src / "cli.py").write_text(
|
||||
"@click.group()\ndef cli():\n pass\n@cli.command('release')\ndef release():\n pass\n"
|
||||
)
|
||||
(src / "config.py").write_text("# config")
|
||||
(src / "ci" / "auto_merge.py").write_text("# auto_merge")
|
||||
(docs / "user" / "cli-commands.md").write_text("No commands here.")
|
||||
(docs / "tech" / "architecture.md").write_text("No modules here.")
|
||||
(docs / "tech" / "ci-cd-workflow.md").write_text("No scripts here.")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--docs-dir", str(docs), "--fail-on-missing"])
|
||||
result = runner.invoke(main, ["--docs-dir", str(docs), "--source-dir", str(src), "--fail-on-missing"])
|
||||
assert result.exit_code == 1
|
||||
|
||||
def test_missing_docs_warn_only(self, tmp_path: Path) -> None:
|
||||
@@ -129,10 +143,55 @@ class TestMain:
|
||||
docs = tmp_path / "docs"
|
||||
(docs / "user").mkdir(parents=True)
|
||||
(docs / "tech").mkdir(parents=True)
|
||||
src = tmp_path / "src" / "devx"
|
||||
src.mkdir(parents=True)
|
||||
(src / "ci").mkdir()
|
||||
(src / "__init__.py").write_text("")
|
||||
(src / "ci" / "__init__.py").write_text("")
|
||||
(src / "cli.py").write_text(
|
||||
"@click.group()\ndef cli():\n pass\n@cli.command('release')\ndef release():\n pass\n"
|
||||
)
|
||||
(src / "config.py").write_text("# config")
|
||||
(src / "ci" / "auto_merge.py").write_text("# auto_merge")
|
||||
(docs / "user" / "cli-commands.md").write_text("No commands here.")
|
||||
(docs / "tech" / "architecture.md").write_text("No modules here.")
|
||||
(docs / "tech" / "ci-cd-workflow.md").write_text("No scripts here.")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--docs-dir", str(docs)])
|
||||
result = runner.invoke(main, ["--docs-dir", str(docs), "--source-dir", str(src)])
|
||||
assert result.exit_code == 0
|
||||
assert "MISSING" in result.output
|
||||
|
||||
def test_auto_detect_scripts_dir(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""When src/ doesn't exist but scripts/ does, auto-detect it."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
docs = tmp_path / "docs"
|
||||
(docs / "user").mkdir(parents=True)
|
||||
(docs / "tech").mkdir(parents=True)
|
||||
scripts = tmp_path / "scripts"
|
||||
scripts.mkdir()
|
||||
(scripts / "cli.py").write_text(
|
||||
"@click.group()\ndef cli():\n pass\n@cli.command('release')\ndef release():\n pass\n"
|
||||
)
|
||||
(scripts / "config.py").write_text("# config")
|
||||
(docs / "user" / "cli-commands.md").write_text("## release\n")
|
||||
(docs / "tech" / "architecture.md").write_text("config.py")
|
||||
(docs / "tech" / "ci-cd-workflow.md").write_text("")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--docs-dir", str(docs)])
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_no_source_dir_falls_back_to_required(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""When no source dir exists, falls back to REQUIRED_MODULES/SCRIPTS."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
docs = tmp_path / "docs"
|
||||
(docs / "user").mkdir(parents=True)
|
||||
(docs / "tech").mkdir(parents=True)
|
||||
(docs / "user" / "cli-commands.md").write_text("")
|
||||
from devx.ci.doc_coverage import REQUIRED_MODULES, REQUIRED_SCRIPTS
|
||||
|
||||
(docs / "tech" / "architecture.md").write_text(" ".join(REQUIRED_MODULES))
|
||||
(docs / "tech" / "ci-cd-workflow.md").write_text(" ".join(REQUIRED_SCRIPTS))
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--docs-dir", str(docs)])
|
||||
# No source dir found, so no CLI commands, but modules/scripts from REQUIRED lists
|
||||
assert result.exit_code == 0
|
||||
|
||||
@@ -0,0 +1,436 @@
|
||||
"""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_docs_structure,
|
||||
check_duplicate_headings,
|
||||
check_heading_hierarchy,
|
||||
check_internal_links,
|
||||
check_required_files,
|
||||
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 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
|
||||
@@ -516,6 +516,36 @@ class TestCheckDocumentation:
|
||||
check_documentation(files, result)
|
||||
assert any("Documentation: OK" in s for s in result.summary)
|
||||
|
||||
def test_tofu_changes_without_docs_warns(self) -> None:
|
||||
result = ReviewResult()
|
||||
files = [{"filename": "tofu/modules/hetzner-vm/main.tf"}]
|
||||
check_documentation(files, result)
|
||||
assert any("WARNING" in s for s in result.summary)
|
||||
|
||||
def test_workflow_changes_info(self) -> None:
|
||||
result = ReviewResult()
|
||||
files = [{"filename": ".gitea/workflows/ci.yml"}]
|
||||
check_documentation(files, result)
|
||||
assert any("INFO" in s for s in result.summary)
|
||||
|
||||
def test_todo_in_doc_patch_warns(self) -> None:
|
||||
result = ReviewResult()
|
||||
files = [{"filename": "docs/guide.md", "patch": "+TODO: fix this later\n+Some content\n"}]
|
||||
check_documentation(files, result)
|
||||
assert any("TODO" in s for s in result.summary)
|
||||
|
||||
def test_todo_in_readme_patch_warns(self) -> None:
|
||||
result = ReviewResult()
|
||||
files = [{"filename": "README.md", "patch": "+FIXME: broken\n"}]
|
||||
check_documentation(files, result)
|
||||
assert any("FIXME" in s for s in result.summary)
|
||||
|
||||
def test_no_todo_in_doc_patch_ok(self) -> None:
|
||||
result = ReviewResult()
|
||||
files = [{"filename": "docs/guide.md", "patch": "+Some content\n"}]
|
||||
check_documentation(files, result)
|
||||
assert not any("TODO" in s for s in result.summary)
|
||||
|
||||
|
||||
class TestCheckTestCoverage:
|
||||
def test_src_changes_without_tests_warns(self) -> None:
|
||||
|
||||
Reference in New Issue
Block a user