"""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]