3556 lines
101 KiB
Python
3556 lines
101 KiB
Python
from __future__ import annotations
|
|
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from collections.abc import Callable, Iterator
|
|
from pathlib import Path, PureWindowsPath
|
|
|
|
import pytest
|
|
|
|
|
|
PROJECT_ROOT = Path(__file__).resolve().parents[3]
|
|
GATE_SCRIPT = PROJECT_ROOT / "scripts" / "check_documentation_integrity.py"
|
|
sys.path.insert(0, str(PROJECT_ROOT))
|
|
|
|
import scripts.check_documentation_integrity as documentation_integrity
|
|
from scripts.check_documentation_integrity import (
|
|
DocumentationIntegrityResult,
|
|
check_documentation_integrity,
|
|
)
|
|
|
|
REQUIRED_DOCUMENTS = (
|
|
"README.md",
|
|
"app/README.md",
|
|
"app/src/storage/README.md",
|
|
"docs/architecture/overview.md",
|
|
"docs/architecture/project_structure.md",
|
|
"docs/architecture/dzentra_target_architecture.md",
|
|
"docs/architecture/trades_feed.md",
|
|
"docs/operations/trades_feed_runtime.md",
|
|
"docs/roadmap/master-roadmap.md",
|
|
"docs/migrations/build_060_30_architecture.md",
|
|
"docs/migrations/build_060_20.md",
|
|
"docs/migrations/build_060_20_architecture.md",
|
|
"docs/migrations/build_060_20_1.md",
|
|
"docs/migrations/build_060_20_1_architecture.md",
|
|
"docs/migrations/build_060_21.md",
|
|
"docs/migrations/build_060_21_architecture.md",
|
|
"docs/migrations/build_060_22.md",
|
|
"docs/migrations/build_060_22_architecture.md",
|
|
"docs/migrations/build_060_23.md",
|
|
"docs/migrations/build_060_23_architecture.md",
|
|
"docs/migrations/build_060_24.md",
|
|
"docs/migrations/build_060_24_architecture.md",
|
|
"docs/migrations/build_060_25.md",
|
|
"docs/migrations/build_060_25_architecture.md",
|
|
"docs/migrations/build_060_26.md",
|
|
"docs/migrations/build_060_26_architecture.md",
|
|
"docs/migrations/build_060_27.md",
|
|
"docs/migrations/build_060_27_architecture.md",
|
|
"docs/migrations/build_060_28.md",
|
|
"docs/migrations/build_060_28_architecture.md",
|
|
"docs/migrations/build_060_29.md",
|
|
"docs/migrations/build_060_29_architecture.md",
|
|
)
|
|
|
|
|
|
def _run_gate(
|
|
repository_root: Path,
|
|
*,
|
|
environment: dict[str, str] | None = None,
|
|
timeout_seconds: float = 60.0,
|
|
) -> subprocess.CompletedProcess[str]:
|
|
return subprocess.run(
|
|
(
|
|
sys.executable,
|
|
str(GATE_SCRIPT),
|
|
"--repository-root",
|
|
str(repository_root),
|
|
),
|
|
cwd=PROJECT_ROOT,
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
timeout=timeout_seconds,
|
|
env=environment,
|
|
)
|
|
|
|
|
|
def _make_repository(tmp_path: Path) -> Path:
|
|
repository_root = tmp_path / "repository"
|
|
for relative_path in REQUIRED_DOCUMENTS:
|
|
target = repository_root / relative_path
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
target.write_text(f"# {target.stem}\n", encoding="utf-8")
|
|
(repository_root / "app" / "tools").mkdir(parents=True, exist_ok=True)
|
|
return repository_root
|
|
|
|
|
|
def _write_readme(repository_root: Path, content: str) -> None:
|
|
(repository_root / "README.md").write_text(content, encoding="utf-8")
|
|
|
|
|
|
def _combined_output(result: subprocess.CompletedProcess[str]) -> str:
|
|
return "\n".join(part for part in (result.stdout, result.stderr) if part)
|
|
|
|
|
|
def test_real_repository_documentation_is_clean() -> None:
|
|
result = _run_gate(PROJECT_ROOT)
|
|
|
|
assert result.returncode == 0, _combined_output(result)
|
|
assert "issues=0" in result.stdout
|
|
|
|
|
|
def test_public_api_supports_overrides_and_rejects_escaping_sources(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
|
|
result = check_documentation_integrity(
|
|
repository_root,
|
|
required_documents=("README.md",),
|
|
markdown_sources=(Path("README.md"),),
|
|
)
|
|
|
|
assert isinstance(result, DocumentationIntegrityResult)
|
|
assert result.is_clean is True
|
|
assert result.scanned_documents == 1
|
|
assert result.checked_links == 0
|
|
assert result.issues == ()
|
|
|
|
for source in (Path("../outside"), Path("docs/../../outside"), Path("/tmp")):
|
|
with pytest.raises(ValueError, match="repository-relative"):
|
|
check_documentation_integrity(
|
|
repository_root,
|
|
required_documents=(),
|
|
markdown_sources=(source,),
|
|
)
|
|
|
|
|
|
def test_missing_required_document_is_reported(tmp_path: Path) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
(repository_root / "docs" / "architecture" / "trades_feed.md").unlink()
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert "MANIFEST_MISSING" in result.stderr
|
|
assert "docs/architecture/trades_feed.md" in result.stderr
|
|
|
|
|
|
def test_required_document_must_be_regular_file(tmp_path: Path) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
document = repository_root / "docs" / "architecture" / "trades_feed.md"
|
|
document.unlink()
|
|
document.mkdir()
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert "MANIFEST_NOT_FILE" in result.stderr
|
|
|
|
|
|
def test_required_document_cannot_be_symlink(tmp_path: Path) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
document = repository_root / "docs" / "architecture" / "trades_feed.md"
|
|
document.unlink()
|
|
document.symlink_to("overview.md")
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert "SYMLINK_TARGET" in result.stderr
|
|
|
|
|
|
def test_existing_file_directory_and_parent_link_are_accepted(tmp_path: Path) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
guide = repository_root / "docs" / "guides" / "guide.md"
|
|
guide.parent.mkdir(parents=True)
|
|
guide.write_text(
|
|
"[root](../../README.md)\n"
|
|
"[directory](../architecture)\n"
|
|
"[file](../architecture/overview.md)\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 0, _combined_output(result)
|
|
|
|
|
|
def test_missing_relative_target_is_reported(tmp_path: Path) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(repository_root, "[missing](docs/missing.md)\n")
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert "MISSING_TARGET" in result.stderr
|
|
assert "target='docs/missing.md'" in result.stderr
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"relative_document",
|
|
("CONTRIBUTING.md", "app/DEVELOPMENT.md"),
|
|
)
|
|
def test_all_root_and_app_level_markdown_files_are_scanned(
|
|
tmp_path: Path,
|
|
relative_document: str,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
document = repository_root / relative_document
|
|
document.parent.mkdir(parents=True, exist_ok=True)
|
|
document.write_text("[missing](missing.md)\n", encoding="utf-8")
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert f"{relative_document}:1: MISSING_TARGET" in result.stderr
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"target",
|
|
(
|
|
"../outside.md",
|
|
"%2e%2e/outside.md",
|
|
"docs/%2e%2e/%2e%2e/outside.md",
|
|
),
|
|
)
|
|
def test_plain_and_encoded_repository_escape_is_rejected(
|
|
tmp_path: Path,
|
|
target: str,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(repository_root, f"[outside]({target})\n")
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert "OUTSIDE_REPOSITORY" in result.stderr
|
|
|
|
|
|
def test_symlink_cancelled_by_parent_segment_is_still_rejected(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
external = tmp_path / "outside"
|
|
nested = external / "nested"
|
|
nested.mkdir(parents=True)
|
|
(external / "README.md").write_text("outside\n", encoding="utf-8")
|
|
(repository_root / "alias").symlink_to(nested, target_is_directory=True)
|
|
_write_readme(repository_root, "[outside](alias/../README.md)\n")
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert "SYMLINK_TARGET" in result.stderr
|
|
|
|
|
|
def test_parent_segment_through_real_directory_stays_inside_repository(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(repository_root, "[root](docs/../README.md)\n")
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 0, _combined_output(result)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("target", "expected_code"),
|
|
(
|
|
("/etc/passwd", "ABSOLUTE_TARGET"),
|
|
("C:/Windows/system.ini", "ABSOLUTE_TARGET"),
|
|
("//server/share", "ABSOLUTE_TARGET"),
|
|
(r"\\server\share", "ABSOLUTE_TARGET"),
|
|
(r"docs\architecture\overview.md", "BACKSLASH_TARGET"),
|
|
("%43%3Afoo", "ABSOLUTE_TARGET"),
|
|
("file:///tmp/document.md", "FORBIDDEN_SCHEME"),
|
|
("data:text/plain,test", "FORBIDDEN_SCHEME"),
|
|
("javascript:alert(1)", "FORBIDDEN_SCHEME"),
|
|
("ftp://example.test/file", "FORBIDDEN_SCHEME"),
|
|
),
|
|
)
|
|
def test_unsafe_absolute_and_scheme_targets_are_rejected(
|
|
tmp_path: Path,
|
|
target: str,
|
|
expected_code: str,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(repository_root, f"[unsafe]({target})\n")
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert expected_code in result.stderr
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"target",
|
|
(
|
|
"docs/architecture/overview.md?raw=1",
|
|
"docs/architecture/overview.md?",
|
|
"docs/architecture/overview.md?#section",
|
|
),
|
|
)
|
|
def test_local_query_string_is_rejected(
|
|
tmp_path: Path,
|
|
target: str,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(repository_root, f"[query]({target})\n")
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert "LOCAL_QUERY" in result.stderr
|
|
|
|
|
|
def test_malformed_url_is_reported_without_stopping_other_documents(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(
|
|
repository_root,
|
|
"[bad](http://[::1)\n"
|
|
"[missing](missing-after-malformed-url.md)\n",
|
|
)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert "MALFORMED_TARGET" in result.stderr
|
|
assert "target='missing-after-malformed-url.md'" in result.stderr
|
|
assert "Traceback" not in result.stderr
|
|
|
|
|
|
def test_case_mismatch_is_reported_portably(tmp_path: Path) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(repository_root, "[case](docs/Architecture/overview.md)\n")
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert "PATH_CASE_MISMATCH" in result.stderr
|
|
|
|
|
|
def test_symlink_target_is_rejected(tmp_path: Path) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
link = repository_root / "docs" / "architecture" / "overview-link.md"
|
|
link.symlink_to("overview.md")
|
|
_write_readme(repository_root, "[link](docs/architecture/overview-link.md)\n")
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert "SYMLINK_TARGET" in result.stderr
|
|
|
|
|
|
def test_symlink_directory_component_is_rejected(tmp_path: Path) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
alias = repository_root / "docs" / "architecture-alias"
|
|
alias.symlink_to("architecture", target_is_directory=True)
|
|
_write_readme(repository_root, "[link](docs/architecture-alias/overview.md)\n")
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert "SYMLINK_TARGET" in result.stderr
|
|
|
|
|
|
def test_symlink_markdown_source_is_reported(tmp_path: Path) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
source = repository_root / "docs" / "linked-source.md"
|
|
source.symlink_to("architecture/overview.md")
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert "SYMLINK_TARGET" in result.stderr
|
|
assert "docs/linked-source.md" in result.stderr
|
|
|
|
|
|
def test_symlink_directory_inside_markdown_scope_is_reported(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
external = tmp_path / "external-docs"
|
|
external.mkdir()
|
|
(external / "hidden.md").write_text(
|
|
"[hidden](missing.md)\n",
|
|
encoding="utf-8",
|
|
)
|
|
linked_directory = repository_root / "docs" / "linked-directory"
|
|
linked_directory.symlink_to(external, target_is_directory=True)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert "SYMLINK_SOURCE" in result.stderr
|
|
assert "docs/linked-directory" in result.stderr
|
|
|
|
|
|
def test_recursive_discovery_reports_io_error(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
|
|
def broken_walk(
|
|
top: str | os.PathLike[str],
|
|
topdown: bool = True,
|
|
onerror: Callable[[OSError], object] | None = None,
|
|
followlinks: bool = False,
|
|
) -> Iterator[tuple[str, list[str], list[str]]]:
|
|
del topdown, followlinks
|
|
if onerror is not None:
|
|
onerror(PermissionError(13, "denied", os.fspath(top)))
|
|
return iter(())
|
|
|
|
monkeypatch.setattr(os, "walk", broken_walk)
|
|
result = check_documentation_integrity(
|
|
repository_root,
|
|
required_documents=(),
|
|
markdown_sources=(Path("docs"),),
|
|
)
|
|
|
|
assert result.is_clean is False
|
|
assert [issue.code for issue in result.issues] == ["SOURCE_IO_ERROR"]
|
|
|
|
|
|
def test_shallow_discovery_reports_io_error(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
original_glob = Path.glob
|
|
|
|
def broken_glob(self: Path, pattern: str) -> Iterator[Path]:
|
|
if self == repository_root:
|
|
raise PermissionError(13, "denied", os.fspath(self))
|
|
return original_glob(self, pattern)
|
|
|
|
monkeypatch.setattr(Path, "glob", broken_glob)
|
|
result = check_documentation_integrity(repository_root)
|
|
|
|
assert result.is_clean is False
|
|
assert "SOURCE_IO_ERROR" in {issue.code for issue in result.issues}
|
|
|
|
|
|
def test_image_requires_file_while_normal_directory_link_is_allowed(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
image = repository_root / "docs" / "image.png"
|
|
image.write_bytes(b"not-a-real-image")
|
|
_write_readme(
|
|
repository_root,
|
|
"\n"
|
|
"[directory](docs/architecture)\n"
|
|
"\n",
|
|
)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert result.stderr.count("IMAGE_NOT_FILE") == 1
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"link",
|
|
(
|
|
"[file](README.md/)",
|
|
"[file](README.md/.)",
|
|
"[file](README.md/missing/..)",
|
|
"",
|
|
),
|
|
)
|
|
def test_file_target_cannot_have_directory_uri_intent(
|
|
tmp_path: Path,
|
|
link: str,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(repository_root, f"{link}\n")
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert "FILE_AS_DIRECTORY" in result.stderr
|
|
|
|
|
|
def test_directory_target_may_have_trailing_slash(tmp_path: Path) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(repository_root, "[directory](docs/architecture/)\n")
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 0, _combined_output(result)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"target",
|
|
("README.md/child", "README.md/child/grandchild"),
|
|
)
|
|
def test_file_cannot_be_intermediate_path_component(
|
|
tmp_path: Path,
|
|
target: str,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(repository_root, f"[child]({target})\n")
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert "FILE_AS_DIRECTORY" in result.stderr
|
|
assert "PATH_IO_ERROR" not in result.stderr
|
|
|
|
|
|
def test_fragments_validate_base_path_but_not_heading_slug(tmp_path: Path) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
plain = repository_root / "docs" / "plain.txt"
|
|
plain.write_text("plain\n", encoding="utf-8")
|
|
_write_readme(
|
|
repository_root,
|
|
"[same](#not-checked)\n"
|
|
"[cross](docs/architecture/overview.md#not-checked)\n"
|
|
"[directory](docs/architecture#section)\n"
|
|
"[plain](docs/plain.txt#section)\n"
|
|
"[empty](docs/architecture/overview.md#)\n",
|
|
)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert "DIRECTORY_FRAGMENT" in result.stderr
|
|
assert "NON_MARKDOWN_FRAGMENT" in result.stderr
|
|
assert "EMPTY_FRAGMENT" in result.stderr
|
|
assert "#not-checked" not in result.stderr
|
|
|
|
|
|
def test_reference_links_images_angle_targets_and_titles_are_supported(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
(repository_root / "docs" / "image.png").write_bytes(b"image")
|
|
_write_readme(
|
|
repository_root,
|
|
"[guide][guide-ref]\n"
|
|
"![image][image-ref]\n"
|
|
"[angle](<docs/architecture/overview.md> \"Overview\")\n"
|
|
"\n"
|
|
"[guide-ref]: docs/architecture/overview.md 'Guide'\n"
|
|
"[image-ref]: docs/image.png\n",
|
|
)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 0, _combined_output(result)
|
|
|
|
|
|
def test_reference_definitions_inside_quote_and_list_are_supported(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(
|
|
repository_root,
|
|
"> [quote][quote-ref]\n"
|
|
">\n"
|
|
"> [quote-ref]: docs/architecture/overview.md\n"
|
|
"- [list][list-ref]\n"
|
|
"- [list-ref]: docs/architecture/project_structure.md\n",
|
|
)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 0, _combined_output(result)
|
|
|
|
|
|
@pytest.mark.parametrize("marker", ("- ", "10. "))
|
|
def test_reference_definition_on_list_continuation_uses_list_indent(
|
|
tmp_path: Path,
|
|
marker: str,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
continuation_indent = " " * len(marker)
|
|
_write_readme(
|
|
repository_root,
|
|
f"{marker}[list][list-ref]\n"
|
|
f"{continuation_indent}\n"
|
|
f"{continuation_indent}[list-ref]: "
|
|
"docs/architecture/overview.md\n",
|
|
)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 0, _combined_output(result)
|
|
|
|
|
|
def test_nested_list_reference_definition_uses_full_container_path(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(
|
|
repository_root,
|
|
"- outer\n"
|
|
" - [nested][nested-ref]\n"
|
|
" \n"
|
|
" [nested-ref]: docs/architecture/overview.md\n",
|
|
)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 0, _combined_output(result)
|
|
|
|
|
|
def test_undefined_and_duplicate_references_are_reported(tmp_path: Path) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(
|
|
repository_root,
|
|
"[missing][unknown]\n"
|
|
"\n"
|
|
"[known]: docs/architecture/overview.md\n"
|
|
"[known]: docs/architecture/project_structure.md\n",
|
|
)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert "UNDEFINED_REFERENCE" in result.stderr
|
|
assert "DUPLICATE_REFERENCE" in result.stderr
|
|
|
|
|
|
def test_links_inside_inline_and_fenced_code_are_ignored(tmp_path: Path) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(
|
|
repository_root,
|
|
"`[inline](missing-inline.md)`\n"
|
|
"```markdown\n"
|
|
"[fenced](missing-fenced.md)\n"
|
|
"```\n"
|
|
"[real](docs/architecture/overview.md)\n",
|
|
)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 0, _combined_output(result)
|
|
|
|
|
|
def test_escaped_markdown_delimiters_use_backslash_parity(tmp_path: Path) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(
|
|
repository_root,
|
|
"\\[literal](missing-literal.md)\n"
|
|
"\\`[real](missing-real.md)\\`\n"
|
|
"\\\\[also-real](missing-even.md)\n",
|
|
)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert "missing-literal.md" not in result.stderr
|
|
assert "target='missing-real.md'" in result.stderr
|
|
assert "target='missing-even.md'" in result.stderr
|
|
|
|
|
|
def test_links_inside_html_comments_are_ignored(tmp_path: Path) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(
|
|
repository_root,
|
|
"<!-- [commented](missing-commented.md) -->\n"
|
|
"[real](docs/architecture/overview.md)\n",
|
|
)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 0, _combined_output(result)
|
|
|
|
|
|
def test_comment_backtick_cannot_mask_link_after_comment(tmp_path: Path) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(
|
|
repository_root,
|
|
"<!-- ` -->\n"
|
|
"[real](missing-after-comment.md)\n"
|
|
"`\n",
|
|
)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert "target='missing-after-comment.md'" in result.stderr
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"paragraph",
|
|
(
|
|
"> paragraph",
|
|
"- paragraph",
|
|
"- > paragraph",
|
|
),
|
|
)
|
|
def test_html_comment_interrupts_lazy_container_paragraph(
|
|
tmp_path: Path,
|
|
paragraph: str,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(
|
|
repository_root,
|
|
f"{paragraph}\n"
|
|
"<!--\n"
|
|
"[inside](missing-inside-html-comment.md)\n"
|
|
"-->\n"
|
|
"[outside](missing-after-html-comment.md)\n",
|
|
)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert "missing-inside-html-comment.md" not in result.stderr
|
|
assert "target='missing-after-html-comment.md'" in result.stderr
|
|
|
|
|
|
def test_comment_markers_inside_code_cannot_mask_link_between_code_spans(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(
|
|
repository_root,
|
|
"`<!--`\n"
|
|
"[real](missing-between-code.md)\n"
|
|
"`-->`\n",
|
|
)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert "target='missing-between-code.md'" in result.stderr
|
|
|
|
|
|
def test_fence_marker_inside_comment_cannot_hide_link_and_unclosed_fence(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(
|
|
repository_root,
|
|
"<!--\n"
|
|
"```\n"
|
|
"-->\n"
|
|
"[real](missing-after-block-comment.md)\n"
|
|
"```\n",
|
|
)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert "target='missing-after-block-comment.md'" in result.stderr
|
|
assert "README.md:5: UNCLOSED_CODE_FENCE" in result.stderr
|
|
|
|
|
|
def test_inline_backtick_before_fence_cannot_consume_block_opener(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(
|
|
repository_root,
|
|
"text ```\n"
|
|
"```\n"
|
|
"[inside](missing-inside-unclosed-fence.md)\n",
|
|
)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert "README.md:2: UNCLOSED_CODE_FENCE" in result.stderr
|
|
assert "missing-inside-unclosed-fence.md" not in result.stderr
|
|
|
|
|
|
def test_inline_code_cannot_cross_blank_line_paragraph_boundary(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(
|
|
repository_root,
|
|
"`\n"
|
|
"[real](missing-before-blank.md)\n"
|
|
"\n"
|
|
"`\n",
|
|
)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert "target='missing-before-blank.md'" in result.stderr
|
|
|
|
|
|
def test_semantic_blank_inside_blockquote_ends_inline_code_span(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(
|
|
repository_root,
|
|
"> `\n"
|
|
"> [real](missing-before-quote-blank.md)\n"
|
|
">\n"
|
|
"> `\n",
|
|
)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert "target='missing-before-quote-blank.md'" in result.stderr
|
|
|
|
|
|
def test_fenced_block_interrupts_inline_span_and_masks_only_fence_content(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(
|
|
repository_root,
|
|
"text ```\n"
|
|
"```\n"
|
|
"[inside](missing-inside-fence.md)\n"
|
|
"```\n"
|
|
"[real](missing-after-fence.md)\n",
|
|
)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert "missing-inside-fence.md" not in result.stderr
|
|
assert "target='missing-after-fence.md'" in result.stderr
|
|
|
|
|
|
def test_atx_heading_ends_inline_span_before_next_paragraph(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(
|
|
repository_root,
|
|
"# `\n"
|
|
"[real](missing-after-heading.md)\n"
|
|
"`\n",
|
|
)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert "target='missing-after-heading.md'" in result.stderr
|
|
|
|
|
|
def test_multiline_inline_code_inside_one_paragraph_is_still_masked(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(
|
|
repository_root,
|
|
"paragraph `[inside](missing-inline-code.md)\n"
|
|
"continues here` after code\n",
|
|
)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 0, _combined_output(result)
|
|
|
|
|
|
def test_pipe_in_ordinary_inline_code_does_not_create_table_cells(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(
|
|
repository_root,
|
|
"paragraph `[inside](missing-inline-pipe.md) | continues` after\n",
|
|
)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 0, _combined_output(result)
|
|
|
|
|
|
def test_pipe_lines_without_delimiter_row_remain_one_inline_paragraph(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(
|
|
repository_root,
|
|
"paragraph `left | [inside](missing-pseudo-table.md)\n"
|
|
"right | continues` after\n",
|
|
)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 0, _combined_output(result)
|
|
|
|
|
|
def test_indented_continuation_cannot_hide_rendered_link(tmp_path: Path) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(
|
|
repository_root,
|
|
"paragraph\n"
|
|
" [real](missing-indented-continuation.md)\n",
|
|
)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert "target='missing-indented-continuation.md'" in result.stderr
|
|
|
|
|
|
def test_nested_list_continuation_cannot_hide_rendered_link(tmp_path: Path) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(
|
|
repository_root,
|
|
"- item\n"
|
|
" [nested](missing-nested-list.md)\n",
|
|
)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert "target='missing-nested-list.md'" in result.stderr
|
|
|
|
|
|
def test_blockquote_fence_is_recognised_as_unclosed_block(tmp_path: Path) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(
|
|
repository_root,
|
|
"> ```\n"
|
|
"> [inside](missing-inside-blockquote-fence.md)\n",
|
|
)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert "README.md:1: UNCLOSED_CODE_FENCE" in result.stderr
|
|
assert "missing-inside-blockquote-fence.md" not in result.stderr
|
|
|
|
|
|
def test_blockquote_fence_cannot_be_closed_by_top_level_marker(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(
|
|
repository_root,
|
|
"> ```\n"
|
|
"> [inside](missing-inside-blockquote.md)\n"
|
|
"[outside](missing-outside-blockquote.md)\n"
|
|
"```\n",
|
|
)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert "README.md:1: UNCLOSED_CODE_FENCE" in result.stderr
|
|
assert "missing-inside-blockquote.md" not in result.stderr
|
|
assert "target='missing-outside-blockquote.md'" in result.stderr
|
|
|
|
|
|
def test_top_level_fence_cannot_be_closed_by_blockquote_marker(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(
|
|
repository_root,
|
|
"```\n"
|
|
"> ```\n"
|
|
"[inside](missing-inside-top-level.md)\n"
|
|
"```\n"
|
|
"[outside](docs/architecture/overview.md)\n",
|
|
)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 0, _combined_output(result)
|
|
|
|
|
|
def test_correctly_closed_blockquote_fence_masks_only_its_content(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(
|
|
repository_root,
|
|
"> ```\n"
|
|
"> [inside](missing-inside-closed-blockquote.md)\n"
|
|
"> ```\n"
|
|
"[outside](missing-after-closed-blockquote.md)\n",
|
|
)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert "missing-inside-closed-blockquote.md" not in result.stderr
|
|
assert "target='missing-after-closed-blockquote.md'" in result.stderr
|
|
|
|
|
|
def test_multiline_inline_code_inside_blockquote_remains_one_span(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(
|
|
repository_root,
|
|
"> paragraph `[inside](missing-inline-blockquote.md)\n"
|
|
"> continues` after\n"
|
|
"[outside](docs/architecture/overview.md)\n",
|
|
)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 0, _combined_output(result)
|
|
|
|
|
|
def test_lazy_blockquote_continuation_keeps_rendered_link_visible(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(
|
|
repository_root,
|
|
"> paragraph\n"
|
|
" [real](missing-lazy-blockquote-link.md)\n",
|
|
)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert "target='missing-lazy-blockquote-link.md'" in result.stderr
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"content",
|
|
(
|
|
"> paragraph `[inside](missing-lazy-quote-code.md)\n"
|
|
"continues` after\n",
|
|
"- paragraph `[inside](missing-lazy-list-code.md)\n"
|
|
"continues` after\n",
|
|
),
|
|
)
|
|
def test_lazy_container_continuation_keeps_inline_code_span(
|
|
tmp_path: Path,
|
|
content: str,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(repository_root, content)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 0, _combined_output(result)
|
|
|
|
|
|
def test_multiline_inline_code_inside_list_item_remains_one_span(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(
|
|
repository_root,
|
|
"- paragraph `[inside](missing-inline-list.md)\n"
|
|
" continues` after\n"
|
|
"[outside](docs/architecture/overview.md)\n",
|
|
)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 0, _combined_output(result)
|
|
|
|
|
|
def test_inline_code_cannot_cross_into_sibling_list_item(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(
|
|
repository_root,
|
|
"- first `\n"
|
|
"- [real](missing-sibling-list-item.md)\n"
|
|
" `\n",
|
|
)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert "target='missing-sibling-list-item.md'" in result.stderr
|
|
|
|
|
|
def test_inline_code_cannot_cross_nested_sibling_list_item(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(
|
|
repository_root,
|
|
"- outer\n"
|
|
" - first `\n"
|
|
" - [real](missing-nested-sibling-list-item.md)\n"
|
|
" `\n",
|
|
)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert "target='missing-nested-sibling-list-item.md'" in result.stderr
|
|
|
|
|
|
def test_ordered_list_fence_uses_its_continuation_indent(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(
|
|
repository_root,
|
|
"10. ```\n"
|
|
" [inside](missing-inside-list-fence.md)\n"
|
|
" ```\n"
|
|
"[outside](missing-after-list-fence.md)\n",
|
|
)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert "missing-inside-list-fence.md" not in result.stderr
|
|
assert "target='missing-after-list-fence.md'" in result.stderr
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"content",
|
|
(
|
|
"- > ```\n"
|
|
" > [inside](missing-list-quote-fence.md)\n"
|
|
" > ```\n",
|
|
"> - ```\n"
|
|
"> [inside](missing-quote-list-fence.md)\n"
|
|
"> ```\n",
|
|
),
|
|
)
|
|
def test_fence_uses_full_ordered_container_path(
|
|
tmp_path: Path,
|
|
content: str,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(repository_root, content)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 0, _combined_output(result)
|
|
|
|
|
|
def test_blank_line_without_quote_marker_splits_list_quote_fences(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(
|
|
repository_root,
|
|
"- > ```\n"
|
|
" > hidden\n"
|
|
"\n"
|
|
" > ```\n"
|
|
" > [after](missing-inside-second-fence.md)\n",
|
|
)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert result.stderr.count("UNCLOSED_CODE_FENCE") == 2
|
|
assert "README.md:1: UNCLOSED_CODE_FENCE" in result.stderr
|
|
assert "README.md:4: UNCLOSED_CODE_FENCE" in result.stderr
|
|
assert "missing-inside-second-fence.md" not in result.stderr
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("content", "target"),
|
|
(
|
|
(
|
|
"- ```\n"
|
|
" ```\n"
|
|
" [real](missing-after-unordered-list-fence.md)\n",
|
|
"missing-after-unordered-list-fence.md",
|
|
),
|
|
(
|
|
"10. ```\n"
|
|
" ```\n"
|
|
" [real](missing-after-ordered-list-fence.md)\n",
|
|
"missing-after-ordered-list-fence.md",
|
|
),
|
|
(
|
|
"- <!-- comment -->\n"
|
|
" [real](missing-after-list-comment.md)\n",
|
|
"missing-after-list-comment.md",
|
|
),
|
|
),
|
|
)
|
|
def test_list_block_preserves_context_for_following_paragraph(
|
|
tmp_path: Path,
|
|
content: str,
|
|
target: str,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(repository_root, content)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert f"target='{target}'" in result.stderr
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"block",
|
|
(
|
|
"- ```\n ```\n",
|
|
"- <!-- comment -->\n",
|
|
"10. ```\n ```\n",
|
|
),
|
|
)
|
|
def test_inline_span_after_list_block_cannot_cross_dedent(
|
|
tmp_path: Path,
|
|
block: str,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
marker_indent = 4 if block.startswith("10.") else 2
|
|
_write_readme(
|
|
repository_root,
|
|
block
|
|
+ " " * (marker_indent + 2)
|
|
+ "paragraph `\n"
|
|
+ "[outside](missing-after-list-dedent.md) `\n",
|
|
)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert "target='missing-after-list-dedent.md'" in result.stderr
|
|
|
|
|
|
def test_gfm_table_cells_are_independent_inline_containers(tmp_path: Path) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(
|
|
repository_root,
|
|
"| A | B | C |\n"
|
|
"|---|---|---|\n"
|
|
"| ` | [real](missing-table-cell.md) | ` |\n",
|
|
)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert "target='missing-table-cell.md'" in result.stderr
|
|
|
|
|
|
def test_gfm_table_without_outer_pipes_is_confirmed_by_delimiter_row(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(
|
|
repository_root,
|
|
"A | B | C\n"
|
|
"---|---|---\n"
|
|
"` | [real](missing-table-without-outer-pipes.md) | `\n",
|
|
)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert "target='missing-table-without-outer-pipes.md'" in result.stderr
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"header",
|
|
(
|
|
"- `[inside](missing-lazy-table-code.md) | x`",
|
|
"> `[inside](missing-lazy-table-code.md) | x`",
|
|
"- > `[inside](missing-lazy-table-code.md) | x`",
|
|
),
|
|
)
|
|
def test_table_delimiter_cannot_be_lazy_container_continuation(
|
|
tmp_path: Path,
|
|
header: str,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(repository_root, f"{header}\n---|---\n")
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 0, _combined_output(result)
|
|
|
|
|
|
def test_indented_table_delimiter_remains_inside_list_container(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(
|
|
repository_root,
|
|
"- A | B\n"
|
|
" ---|---\n"
|
|
" [real](missing-inside-list-table.md) | value\n",
|
|
)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert "target='missing-inside-list-table.md'" in result.stderr
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"content",
|
|
(
|
|
"A | B\n"
|
|
"---|---\n"
|
|
"- paragraph `[inside](missing-after-table-list.md) | continues`\n",
|
|
"A | B\n"
|
|
"---|---\n"
|
|
"# heading `[inside](missing-after-table-heading.md) | continues`\n",
|
|
"- A | B\n"
|
|
" ---|---\n"
|
|
"- paragraph `[inside](missing-sibling-after-list-table.md) | continues`\n",
|
|
"- `[inside](missing-between-sibling-items.md) | text`\n"
|
|
"- ---|---\n",
|
|
),
|
|
)
|
|
def test_gfm_table_does_not_cross_new_block_or_list_item(
|
|
tmp_path: Path,
|
|
content: str,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(repository_root, content)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 0, _combined_output(result)
|
|
|
|
|
|
def test_top_level_and_list_indented_code_are_masked_until_dedent(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(
|
|
repository_root,
|
|
" [first](missing-top-level-indented.md)\n"
|
|
"paragraph\n"
|
|
"\n"
|
|
" [second](missing-after-blank-indented.md)\n"
|
|
"- item\n"
|
|
"\n"
|
|
" [third](missing-list-indented.md)\n"
|
|
"> quote block\n"
|
|
" [fourth](missing-after-block-indented.md)\n"
|
|
"[outside](missing-after-indented-code.md)\n",
|
|
)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert "missing-top-level-indented.md" not in result.stderr
|
|
assert "missing-after-blank-indented.md" not in result.stderr
|
|
assert "missing-list-indented.md" not in result.stderr
|
|
assert "target='missing-after-block-indented.md'" in result.stderr
|
|
assert "target='missing-after-indented-code.md'" in result.stderr
|
|
|
|
|
|
def test_indented_code_after_quote_setext_and_table_blocks_is_masked(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(
|
|
repository_root,
|
|
">\n"
|
|
"> [quote](missing-quote-indented.md)\n"
|
|
"Heading\n"
|
|
"=======\n"
|
|
" [setext](missing-setext-indented.md)\n"
|
|
"A | B\n"
|
|
"---|---\n"
|
|
"x | y\n"
|
|
" [table](missing-table-indented.md)\n",
|
|
)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 0, _combined_output(result)
|
|
|
|
|
|
def test_many_unique_unmatched_backtick_runs_complete_within_cli_timeout(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(
|
|
repository_root,
|
|
"\n".join(
|
|
f"text {'`' * run_length} unmatched"
|
|
for run_length in range(1, 701)
|
|
),
|
|
)
|
|
|
|
result = _run_gate(repository_root, timeout_seconds=15.0)
|
|
|
|
assert result.returncode == 0, _combined_output(result)
|
|
|
|
|
|
def test_closing_parenthesis_inside_quoted_title_is_supported(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(
|
|
repository_root,
|
|
'[valid](docs/architecture/overview.md "title ) remains title")\n',
|
|
)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 0, _combined_output(result)
|
|
|
|
|
|
def test_unclosed_fenced_code_is_reported_at_opening_line(tmp_path: Path) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(repository_root, "# Title\n\n```python\nprint('open')\n")
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert "README.md:3: UNCLOSED_CODE_FENCE" in result.stderr
|
|
|
|
|
|
def test_raw_html_link_is_rejected(tmp_path: Path) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(
|
|
repository_root,
|
|
'<a href="docs/architecture/overview.md">overview</a>\n',
|
|
)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert "UNSUPPORTED_HTML_LINK" in result.stderr
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"html_link",
|
|
(
|
|
'<a title=">" href="missing.md">text</a>',
|
|
"<img alt='>' src='missing.png'>",
|
|
),
|
|
)
|
|
def test_raw_html_link_with_angle_bracket_inside_quoted_attribute_is_rejected(
|
|
tmp_path: Path,
|
|
html_link: str,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(repository_root, f"{html_link}\n")
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert "UNSUPPORTED_HTML_LINK" in result.stderr
|
|
|
|
|
|
def test_raw_html_attribute_name_inside_quoted_value_is_not_a_link(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(
|
|
repository_root,
|
|
'<a title="href=missing.md">text without link</a>\n',
|
|
)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 0, _combined_output(result)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"content",
|
|
(
|
|
'<div title="<a href=missing.md>">plain</div>',
|
|
"<div title='<IMG SRC=missing.png>'>plain</div>",
|
|
"< a href=missing.md>",
|
|
),
|
|
)
|
|
def test_target_like_text_outside_real_raw_html_link_is_ignored(
|
|
tmp_path: Path,
|
|
content: str,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(repository_root, f"{content}\n")
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 0, _combined_output(result)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"content",
|
|
(
|
|
'<span title="[inside](missing-span-attribute.md)">text</span>',
|
|
'<a title="[inside](missing-anchor-attribute.md)">text</a>',
|
|
'<img alt="[inside](missing-image-attribute.md)">',
|
|
),
|
|
)
|
|
def test_markdown_shape_inside_raw_html_attribute_is_not_a_link(
|
|
tmp_path: Path,
|
|
content: str,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(repository_root, f"{content}\n")
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 0, _combined_output(result)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"content",
|
|
(
|
|
'<a <img src="missing.png">',
|
|
'<a title=x <a href="missing.md">x</a>',
|
|
'<a title="unterminated <img src=missing.png>',
|
|
),
|
|
)
|
|
def test_raw_html_link_after_malformed_candidate_is_rejected(
|
|
tmp_path: Path,
|
|
content: str,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(repository_root, f"{content}\n")
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert "UNSUPPORTED_HTML_LINK" in result.stderr
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"content",
|
|
(
|
|
'</span title="[real](missing-invalid-closing-tag.md)">',
|
|
'<span !!! [real](missing-invalid-opening-tag.md)>',
|
|
'<span =bad [real](missing-invalid-equals-tag.md)>',
|
|
),
|
|
)
|
|
def test_malformed_raw_html_tag_cannot_hide_markdown_link(
|
|
tmp_path: Path,
|
|
content: str,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(repository_root, f"{content}\n")
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert "MISSING_TARGET" in result.stderr
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"content",
|
|
(
|
|
"<a href>text</a>",
|
|
"<img src>",
|
|
"<A HREF >text</A>",
|
|
),
|
|
)
|
|
def test_valueless_raw_html_link_attribute_is_rejected(
|
|
tmp_path: Path,
|
|
content: str,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(repository_root, f"{content}\n")
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert "UNSUPPORTED_HTML_LINK" in result.stderr
|
|
|
|
|
|
def test_tag_shaped_angle_destination_has_markdown_precedence(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(
|
|
repository_root,
|
|
"[inline](<docs>)\n"
|
|
"[reference][docs-ref]\n"
|
|
"\n"
|
|
"[docs-ref]: <docs>\n"
|
|
'[title](docs "<a href=missing.md>")\n',
|
|
)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 0, _combined_output(result)
|
|
|
|
|
|
def test_multiline_raw_html_attribute_cannot_define_reference(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(
|
|
repository_root,
|
|
'<span title="\n'
|
|
"[inside]: missing-reference-in-attribute.md\n"
|
|
'">text</span>\n',
|
|
)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 0, _combined_output(result)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"definition",
|
|
(
|
|
'[docs-ref]: README.md\n "<a href=x> [inside](missing-title.md)"',
|
|
'> [docs-ref]: README.md\n> "<a href=x> [inside](missing-title.md)"',
|
|
'- [docs-ref]: README.md\n "<a href=x> [inside](missing-title.md)"',
|
|
),
|
|
)
|
|
def test_reference_title_on_next_line_stays_in_same_container(
|
|
tmp_path: Path,
|
|
definition: str,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(
|
|
repository_root,
|
|
f"[reference][docs-ref]\n\n{definition}\n",
|
|
)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 0, _combined_output(result)
|
|
|
|
|
|
def test_reference_title_in_different_container_is_not_protected(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(
|
|
repository_root,
|
|
'> [docs-ref]: README.md\n<a href=x>\n',
|
|
)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert "UNSUPPORTED_HTML_LINK" in result.stderr
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"content",
|
|
(
|
|
'<a\n\nhref=missing.md>text</a>',
|
|
'<a title=x\fhref=missing.md>text</a>',
|
|
),
|
|
)
|
|
def test_invalid_raw_html_spacing_does_not_create_link_tag(
|
|
tmp_path: Path,
|
|
content: str,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(repository_root, f"{content}\n")
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 0, _combined_output(result)
|
|
|
|
|
|
def test_one_line_ending_inside_raw_html_tag_is_supported(tmp_path: Path) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(repository_root, '<a\n href=missing.md>text</a>\n')
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert "UNSUPPORTED_HTML_LINK" in result.stderr
|
|
|
|
|
|
def test_many_unmatched_brackets_complete_within_cli_timeout(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(repository_root, "[" * 20_000 + "\n")
|
|
|
|
result = _run_gate(repository_root, timeout_seconds=15.0)
|
|
|
|
assert result.returncode == 0, _combined_output(result)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"content",
|
|
(
|
|
'[real <span title="]">text</span>](README.md "<a href=x>")',
|
|
),
|
|
)
|
|
def test_inline_tokens_inside_label_do_not_close_markdown_link(
|
|
tmp_path: Path,
|
|
content: str,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(repository_root, f"{content}\n")
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 0, _combined_output(result)
|
|
|
|
|
|
def test_autolink_inside_label_deactivates_outer_link(tmp_path: Path) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(
|
|
repository_root,
|
|
'[real <https://example.test/]>](README.md "<a href=x>")\n',
|
|
)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert "UNSUPPORTED_HTML_LINK" in result.stderr
|
|
assert "links=1" in result.stderr
|
|
|
|
|
|
def test_escaped_autolink_does_not_receive_token_priority(tmp_path: Path) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(
|
|
repository_root,
|
|
r"[real \<https://example.test/]>](missing.md)" "\n",
|
|
)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 0, _combined_output(result)
|
|
|
|
|
|
def test_inner_link_deactivates_outer_link_and_exposes_outer_title(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(
|
|
repository_root,
|
|
'[outer [inner](missing-inner.md)](README.md "<a href=x>")\n',
|
|
)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert "target='missing-inner.md'" in result.stderr
|
|
assert "UNSUPPORTED_HTML_LINK" in result.stderr
|
|
assert "links=1" in result.stderr
|
|
|
|
|
|
def test_image_inside_link_preserves_both_targets(tmp_path: Path) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(
|
|
repository_root,
|
|
"[](missing-outer.md)\n",
|
|
)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert "target='missing-image.png'" in result.stderr
|
|
assert "target='missing-outer.md'" in result.stderr
|
|
assert "links=2" in result.stderr
|
|
|
|
|
|
def test_link_inside_image_preserves_both_targets(tmp_path: Path) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(
|
|
repository_root,
|
|
"](missing-image.png)\n",
|
|
)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert "target='missing-inner.md'" in result.stderr
|
|
assert "target='missing-image.png'" in result.stderr
|
|
assert "links=2" in result.stderr
|
|
|
|
|
|
def test_malformed_outer_angle_target_does_not_hide_inner_link(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(
|
|
repository_root,
|
|
"[bad](< [good](missing-good.md)\n",
|
|
)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert "MALFORMED_LINK" in result.stderr
|
|
assert "target='missing-good.md'" in result.stderr
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("target", "filename"),
|
|
(
|
|
("<foo(bar>", "foo(bar"),
|
|
("<foo)bar>", "foo)bar"),
|
|
(r"<foo\>bar>", "foo>bar"),
|
|
),
|
|
)
|
|
def test_angle_destination_supports_parentheses_and_escaped_closer(
|
|
tmp_path: Path,
|
|
target: str,
|
|
filename: str,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
(repository_root / filename).write_text("target\n", encoding="utf-8")
|
|
_write_readme(repository_root, f"[valid]({target})\n")
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 0, _combined_output(result)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"content",
|
|
(
|
|
"[invalid](<foo\nbar>)\n",
|
|
"[invalid](<foo<a href=x>)\n",
|
|
),
|
|
)
|
|
def test_invalid_angle_destination_is_not_protected(
|
|
tmp_path: Path,
|
|
content: str,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(repository_root, content)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert "MALFORMED_LINK" in result.stderr
|
|
|
|
|
|
def test_angle_destination_tab_reaches_target_control_policy(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(repository_root, "[invalid](<foo\tbar>)\n")
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert "CONTROL_CHARACTER" in result.stderr
|
|
assert "MALFORMED_LINK" not in result.stderr
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"content",
|
|
(
|
|
'[real](\n\nREADME.md "<a href=x>")\n',
|
|
'[real](README.md\n\n"<a href=x>")\n',
|
|
'[real](README.md "<a href=x>"\n\n)\n',
|
|
'[real](<README.md>\n\n"<a href=x>")\n',
|
|
),
|
|
)
|
|
def test_blank_line_around_link_title_is_not_protected(
|
|
tmp_path: Path,
|
|
content: str,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(repository_root, content)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert "UNSUPPORTED_HTML_LINK" in result.stderr
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"content",
|
|
(
|
|
'[id]: README.md "<a href=x>\\"\n',
|
|
r"[id]: README.md (<a href=x>\)" "\n",
|
|
),
|
|
)
|
|
def test_escaped_final_title_delimiter_is_not_protected(
|
|
tmp_path: Path,
|
|
content: str,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(repository_root, content)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert "UNSUPPORTED_HTML_LINK" in result.stderr
|
|
|
|
|
|
def test_forbidden_uri_autolink_is_validated(tmp_path: Path) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(repository_root, "<ftp://example.test/file>\n")
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert "FORBIDDEN_SCHEME" in result.stderr
|
|
|
|
|
|
def test_cr_only_line_numbers_are_reported_correctly(tmp_path: Path) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(repository_root, "ok\r[x](missing.md)\r")
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert "README.md:2" in result.stderr
|
|
|
|
|
|
def test_form_feed_does_not_start_reference_definition(tmp_path: Path) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(repository_root, "plain\f[id]: README.md\n[use][id]\n")
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert "UNDEFINED_REFERENCE" in result.stderr
|
|
|
|
|
|
def test_long_title_escape_parity_uses_forward_scan() -> None:
|
|
assert documentation_integrity._valid_link_title(
|
|
'"' + "\\" * 40_000 + '"'
|
|
)
|
|
assert not documentation_integrity._valid_link_title(
|
|
'"' + "\\" * 40_001 + '"'
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("source", ("[a](<" * 20_000, '[a](x "' * 20_000))
|
|
def test_many_semantic_link_failures_use_indexed_scan(source: str) -> None:
|
|
links, issues = documentation_integrity._parse_markdown_links(
|
|
source,
|
|
source="README.md",
|
|
)
|
|
|
|
assert links == ()
|
|
assert len(issues) == 20_000
|
|
|
|
|
|
@pytest.mark.parametrize("prefix", ("<!-- ", "<?x "))
|
|
def test_many_unclosed_inline_html_starts_use_bounded_scan(
|
|
prefix: str,
|
|
) -> None:
|
|
source = "prefix " + prefix * 20_000 + "[real](missing.md)\n"
|
|
|
|
links, issues = documentation_integrity._parse_markdown_links(
|
|
source,
|
|
source="README.md",
|
|
)
|
|
|
|
assert [link.target for link in links] == ["missing.md"]
|
|
assert issues == ()
|
|
|
|
|
|
def test_many_line_numbers_use_shared_index() -> None:
|
|
source = "\n".join("[a](README.md)" for _ in range(20_000))
|
|
|
|
links, issues = documentation_integrity._parse_markdown_links(
|
|
source,
|
|
source="README.md",
|
|
)
|
|
|
|
assert len(links) == 20_000
|
|
assert links[-1].line == 20_000
|
|
assert issues == ()
|
|
|
|
|
|
def test_deep_container_prefix_is_consumed_in_one_pass() -> None:
|
|
deep_line = "> " * 4_000 + "text\n"
|
|
shallower_line = "> " * 2_000 + "continuation\n"
|
|
|
|
contexts = documentation_integrity._build_line_contexts(
|
|
deep_line + shallower_line
|
|
)
|
|
|
|
assert len(contexts) == 2
|
|
assert contexts[1].container.quote_depth == 4_000
|
|
assert contexts[1].semantic_content == "continuation"
|
|
|
|
|
|
def test_exact_path_inspection_reuses_directory_cache(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
repository_root = tmp_path / "repository"
|
|
repository_root.mkdir()
|
|
targets = tuple(repository_root / f"target-{index}.md" for index in range(500))
|
|
for target in targets:
|
|
target.write_text("target\n", encoding="utf-8")
|
|
|
|
calls = 0
|
|
original_iterdir = Path.iterdir
|
|
|
|
def counting_iterdir(path: Path) -> Iterator[Path]:
|
|
nonlocal calls
|
|
calls += 1
|
|
return original_iterdir(path)
|
|
|
|
monkeypatch.setattr(Path, "iterdir", counting_iterdir)
|
|
directory_cache: dict[Path, documentation_integrity._DirectoryIndex] = {}
|
|
inspection_cache: dict[str, documentation_integrity._PathInspection] = {}
|
|
for target in targets:
|
|
inspection = documentation_integrity._inspect_exact_path(
|
|
repository_root,
|
|
target,
|
|
directory_cache=directory_cache,
|
|
inspection_cache=inspection_cache,
|
|
)
|
|
assert inspection.path == target
|
|
|
|
assert calls == 1
|
|
|
|
|
|
def test_windows_path_cache_keys_preserve_exact_case() -> None:
|
|
exact = documentation_integrity._path_cache_key(
|
|
PureWindowsPath("C:/repo/docs/Foo.md")
|
|
)
|
|
wrong_case = documentation_integrity._path_cache_key(
|
|
PureWindowsPath("C:/repo/docs/foo.md")
|
|
)
|
|
|
|
assert exact != wrong_case
|
|
|
|
|
|
def test_junction_component_is_rejected_as_target_indirection(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
junction = repository_root / "docs" / "architecture"
|
|
original_is_junction = Path.is_junction
|
|
|
|
def fake_is_junction(path: Path) -> bool:
|
|
return path == junction or original_is_junction(path)
|
|
|
|
monkeypatch.setattr(Path, "is_junction", fake_is_junction)
|
|
inspection = documentation_integrity._inspect_exact_path(
|
|
repository_root,
|
|
junction / "overview.md",
|
|
)
|
|
|
|
assert inspection.path is None
|
|
assert inspection.issue_code == "SYMLINK_TARGET"
|
|
|
|
|
|
def test_junction_directory_is_pruned_from_documentation_scope(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
junction = repository_root / "docs" / "junction"
|
|
junction.mkdir()
|
|
(junction / "hidden.md").write_text("hidden\n", encoding="utf-8")
|
|
original_is_junction = Path.is_junction
|
|
|
|
def fake_is_junction(path: Path) -> bool:
|
|
return path == junction or original_is_junction(path)
|
|
|
|
monkeypatch.setattr(Path, "is_junction", fake_is_junction)
|
|
result = check_documentation_integrity(repository_root)
|
|
|
|
assert "SYMLINK_SOURCE" in {issue.code for issue in result.issues}
|
|
assert "docs/junction" in {issue.source for issue in result.issues}
|
|
|
|
|
|
def test_single_crlf_is_allowed_between_destination_and_title(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(repository_root, '[x](README.md\r\n"title")\r\n')
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 0, _combined_output(result)
|
|
|
|
|
|
def test_single_crlf_reference_title_continuation_is_allowed(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(
|
|
repository_root,
|
|
'[id]: README.md\r\n"title"\r\n\r\n[use][id]\r\n',
|
|
)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 0, _combined_output(result)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"nested",
|
|
("[inner][id]", "[inner][]", "[inner]"),
|
|
)
|
|
def test_nested_reference_deactivates_outer_inline_link(nested: str) -> None:
|
|
label = "inner" if nested != "[inner][id]" else "id"
|
|
links, issues = documentation_integrity._parse_markdown_links(
|
|
f"[{label}]: inner.md\n[outer {nested}](outer.md)\n",
|
|
source="README.md",
|
|
)
|
|
|
|
assert [link.target for link in links] == ["inner.md", "inner.md"]
|
|
assert issues == ()
|
|
|
|
|
|
def test_nested_inline_link_deactivates_outer_but_suffix_is_shortcut() -> None:
|
|
links, issues = documentation_integrity._parse_markdown_links(
|
|
"[outer-id]: outer.md\n[outer [inner](inner.md)][outer-id]\n",
|
|
source="README.md",
|
|
)
|
|
|
|
assert [link.target for link in links] == [
|
|
"outer.md",
|
|
"inner.md",
|
|
"outer.md",
|
|
]
|
|
assert issues == ()
|
|
|
|
|
|
def test_image_inside_outer_reference_preserves_both_links() -> None:
|
|
links, issues = documentation_integrity._parse_markdown_links(
|
|
"[outer-id]: outer.md\n[][outer-id]\n",
|
|
source="README.md",
|
|
)
|
|
|
|
assert [link.target for link in links] == [
|
|
"outer.md",
|
|
"outer.md",
|
|
"image.png",
|
|
]
|
|
assert [link.is_image for link in links] == [False, False, True]
|
|
assert issues == ()
|
|
|
|
|
|
def test_link_inside_reference_image_preserves_both_links() -> None:
|
|
links, issues = documentation_integrity._parse_markdown_links(
|
|
"[image-id]: image.png\n][image-id]\n",
|
|
source="README.md",
|
|
)
|
|
|
|
assert [link.target for link in links] == [
|
|
"image.png",
|
|
"image.png",
|
|
"inner.md",
|
|
]
|
|
assert [link.is_image for link in links] == [False, True, False]
|
|
assert issues == ()
|
|
|
|
|
|
def test_link_shaped_text_inside_valid_title_is_not_parsed() -> None:
|
|
links, issues = documentation_integrity._parse_markdown_links(
|
|
'[outer](README.md "[fake](")\n',
|
|
source="README.md",
|
|
)
|
|
|
|
assert [link.target for link in links] == ["README.md"]
|
|
assert issues == ()
|
|
|
|
|
|
def test_link_shaped_text_inside_valid_angle_target_is_not_parsed() -> None:
|
|
links, issues = documentation_integrity._parse_markdown_links(
|
|
"[outer](<[fake](>)\n",
|
|
source="README.md",
|
|
)
|
|
|
|
assert [link.target for link in links] == ["[fake]("]
|
|
assert issues == ()
|
|
|
|
|
|
def test_image_argument_tokens_do_not_deactivate_outer_link() -> None:
|
|
links, issues = documentation_integrity._parse_markdown_links(
|
|
"[outer ](outer.md)\n",
|
|
source="README.md",
|
|
)
|
|
|
|
assert [link.target for link in links] == [
|
|
"outer.md",
|
|
"https://img.example/x",
|
|
]
|
|
assert issues == ()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"content",
|
|
(
|
|
'[open\n\nlabel](README.md "<a href=x>")\n',
|
|
'[open\n# heading\nlabel](README.md "<a href=x>")\n',
|
|
'[open\n> quote\nlabel](README.md "<a href=x>")\n',
|
|
'- [open\n- label](README.md "<a href=x>")\n',
|
|
'| [open | close](README.md "<a href=x>") |\n| --- | --- |\n',
|
|
'[open\n```text\ncode\n```\nlabel](README.md "<a href=x>")\n',
|
|
'[open\n<div>block</div>\nlabel](README.md "<a href=x>")\n',
|
|
),
|
|
)
|
|
def test_link_label_cannot_cross_block_boundary(
|
|
tmp_path: Path,
|
|
content: str,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(repository_root, content)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert "UNSUPPORTED_HTML_LINK" in result.stderr
|
|
|
|
|
|
def test_link_label_allows_one_soft_line_ending(tmp_path: Path) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(repository_root, "[open\nlabel](README.md)\n")
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 0, _combined_output(result)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"content",
|
|
(
|
|
'[x](\n# "<a href=x>")\n',
|
|
'[x](\n> "<a href=x>")\n',
|
|
'[x](\n- "<a href=x>")\n',
|
|
'[x](\n---\n"<a href=x>")\n',
|
|
),
|
|
)
|
|
def test_inline_argument_cannot_cross_block_boundary(
|
|
tmp_path: Path,
|
|
content: str,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(repository_root, content)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert "UNSUPPORTED_HTML_LINK" in result.stderr
|
|
|
|
|
|
def test_malformed_reference_does_not_hide_same_line_link() -> None:
|
|
links, issues = documentation_integrity._parse_markdown_links(
|
|
"[id]: README.md trailing [real](missing.md)\n",
|
|
source="README.md",
|
|
)
|
|
|
|
assert [link.target for link in links] == ["missing.md"]
|
|
assert [issue.code for issue in issues] == ["MALFORMED_REFERENCE"]
|
|
|
|
|
|
def test_malformed_reference_does_not_hide_next_line_link() -> None:
|
|
links, issues = documentation_integrity._parse_markdown_links(
|
|
'[id]: README.md trailing\n"[real](missing.md)"\n',
|
|
source="README.md",
|
|
)
|
|
|
|
assert [link.target for link in links] == ["missing.md"]
|
|
assert [issue.code for issue in issues] == ["MALFORMED_REFERENCE"]
|
|
|
|
|
|
def test_reference_definition_cannot_interrupt_paragraph(tmp_path: Path) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(
|
|
repository_root,
|
|
'paragraph\n[id]: README.md "<a href=x>"\n\n[use][id]\n',
|
|
)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert "UNSUPPORTED_HTML_LINK" in result.stderr
|
|
|
|
|
|
def test_escaped_reference_label_is_matched_and_validated(tmp_path: Path) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(
|
|
repository_root,
|
|
r"[foo\]]: missing.md" "\n\n" r"[foo\]]" "\n",
|
|
)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert "target='missing.md'" in result.stderr
|
|
|
|
|
|
@pytest.mark.parametrize("prefix", ("\f", "\v", "\N{NO-BREAK SPACE}"))
|
|
def test_non_commonmark_whitespace_does_not_form_continuation_title(
|
|
tmp_path: Path,
|
|
prefix: str,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(
|
|
repository_root,
|
|
f'[id]: README.md\n{prefix}"<a href=x>"\n',
|
|
)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert "UNSUPPORTED_HTML_LINK" in result.stderr
|
|
|
|
|
|
def test_nested_argument_candidates_are_not_rescanned_quadratically() -> None:
|
|
source = "[x](" * 8_000 + "target" + ")" * 8_000
|
|
|
|
links, issues = documentation_integrity._parse_markdown_links(
|
|
source,
|
|
source="README.md",
|
|
)
|
|
|
|
assert len(links) == 1
|
|
assert len(issues) == 8_000 - 33
|
|
assert {issue.code for issue in issues} == {"MALFORMED_LINK"}
|
|
|
|
|
|
def test_balanced_nested_labels_do_not_normalise_every_suffix() -> None:
|
|
source = "[" * 64_000 + "x" + "]" * 64_000
|
|
|
|
links, issues = documentation_integrity._parse_markdown_links(
|
|
source,
|
|
source="README.md",
|
|
)
|
|
|
|
assert links == ()
|
|
assert issues == ()
|
|
|
|
|
|
def test_path_symlink_probe_error_is_reported(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
repository_root = tmp_path / "repository"
|
|
repository_root.mkdir()
|
|
target = repository_root / "target.md"
|
|
target.write_text("target\n", encoding="utf-8")
|
|
original_is_symlink = Path.is_symlink
|
|
|
|
def broken_is_symlink(path: Path) -> bool:
|
|
if path == target:
|
|
raise PermissionError("denied")
|
|
return original_is_symlink(path)
|
|
|
|
monkeypatch.setattr(Path, "is_symlink", broken_is_symlink)
|
|
|
|
inspection = documentation_integrity._inspect_exact_path(
|
|
repository_root,
|
|
target,
|
|
)
|
|
|
|
assert inspection.issue_code == "PATH_IO_ERROR"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"content",
|
|
(
|
|
'<script>\nconst value = "[fake](missing.md)";\n</script>\n',
|
|
'<style>\n.fake { value: "[fake](missing.md)"; }\n</style>\n',
|
|
'<pre>\n[fake](missing.md)\n</pre>\n',
|
|
'<?render value="[fake](missing.md)"?>\n',
|
|
'<!DOCTYPE demo "[fake](missing.md)">\n',
|
|
'<]]>\n',
|
|
),
|
|
)
|
|
def test_commonmark_raw_html_blocks_do_not_expose_markdown_links(
|
|
tmp_path: Path,
|
|
content: str,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(repository_root, content)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 0, _combined_output(result)
|
|
|
|
|
|
def test_reference_title_continuation_does_not_open_paragraph() -> None:
|
|
links, issues = documentation_integrity._parse_markdown_links(
|
|
'[one]: README.md\n"title"\n[two]: missing-two.md\n[use][two]\n',
|
|
source="README.md",
|
|
)
|
|
|
|
assert [link.target for link in links] == [
|
|
"README.md",
|
|
"missing-two.md",
|
|
"missing-two.md",
|
|
]
|
|
assert issues == ()
|
|
|
|
|
|
def test_internal_title_delimiter_cannot_protect_invalid_reference_tail(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(
|
|
repository_root,
|
|
'[id]: README.md "<a href=x>" [real](missing-inline-title.md) "\n',
|
|
)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert "MALFORMED_REFERENCE" in result.stderr
|
|
|
|
|
|
def test_invalid_continuation_title_does_not_hide_its_content(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(
|
|
repository_root,
|
|
'[id]: README.md\n"<a href=x>" [real](missing-title-tail.md) "\n',
|
|
)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert "UNSUPPORTED_HTML_LINK" in result.stderr
|
|
assert "target='missing-title-tail.md'" in result.stderr
|
|
|
|
|
|
def test_blank_line_inside_inline_title_is_not_protected(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(
|
|
repository_root,
|
|
'[real](README.md "\n\n<a href=x>\n")\n',
|
|
)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert "UNSUPPORTED_HTML_LINK" in result.stderr
|
|
|
|
|
|
def test_escaped_internal_title_delimiter_is_supported(tmp_path: Path) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(
|
|
repository_root,
|
|
'[id]: README.md "before \\"quoted\\" after"\n',
|
|
)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 0, _combined_output(result)
|
|
|
|
|
|
def test_many_unclosed_link_parentheses_use_indexed_failure_path() -> None:
|
|
links, issues = documentation_integrity._parse_markdown_links(
|
|
"[a](" * 5_000,
|
|
source="README.md",
|
|
)
|
|
|
|
assert links == ()
|
|
assert len(issues) == 5_000
|
|
|
|
|
|
def test_many_reference_definitions_use_indexed_range_lookup() -> None:
|
|
definitions = "\n".join(
|
|
f"[reference-{index}]: README.md" for index in range(8_000)
|
|
)
|
|
|
|
links, issues = documentation_integrity._parse_markdown_links(
|
|
definitions,
|
|
source="README.md",
|
|
)
|
|
|
|
assert len(links) == 8_000
|
|
assert issues == ()
|
|
|
|
|
|
def test_many_resolved_reference_siblings_use_bounded_selection() -> None:
|
|
source = "[id]: README.md\n\n" + "\n".join(
|
|
"[text][id]" for _ in range(8_000)
|
|
)
|
|
|
|
links, issues = documentation_integrity._parse_markdown_links(
|
|
source,
|
|
source="README.md",
|
|
)
|
|
|
|
assert len(links) == 8_001
|
|
assert issues == ()
|
|
|
|
|
|
def test_unclosed_multiline_reference_title_uses_bounded_scan(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
source = '[foo]: /url "\n' + "x\n" * 8_000
|
|
original_parse = documentation_integrity._parse_destination_and_title
|
|
scanned_characters = 0
|
|
|
|
def recording_parse(value: str) -> tuple[str | None, bool, str | None]:
|
|
nonlocal scanned_characters
|
|
scanned_characters += len(value)
|
|
return original_parse(value)
|
|
|
|
monkeypatch.setattr(
|
|
documentation_integrity,
|
|
"_parse_destination_and_title",
|
|
recording_parse,
|
|
)
|
|
|
|
documentation_integrity._parse_markdown_links(
|
|
source,
|
|
source="README.md",
|
|
)
|
|
|
|
assert scanned_characters <= len(source) * 10
|
|
|
|
|
|
def test_markdown_between_raw_html_tags_remains_visible(tmp_path: Path) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(
|
|
repository_root,
|
|
'<span title="label">[real](missing-between-html-tags.md)</span>\n',
|
|
)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert "target='missing-between-html-tags.md'" in result.stderr
|
|
|
|
|
|
def test_definition_shaped_line_cannot_break_multiline_link_label() -> None:
|
|
links, issues = documentation_integrity._parse_markdown_links(
|
|
"[outer\n[id]: literal text\nlabel](missing.md)\n",
|
|
source="README.md",
|
|
)
|
|
|
|
assert [link.target for link in links] == ["missing.md"]
|
|
assert issues == ()
|
|
|
|
|
|
def test_inline_html_tag_cannot_break_multiline_link_label() -> None:
|
|
links, issues = documentation_integrity._parse_markdown_links(
|
|
"<span>[outer\nlabel](missing.md)</span>\n",
|
|
source="README.md",
|
|
)
|
|
|
|
assert [link.target for link in links] == ["missing.md"]
|
|
assert issues == ()
|
|
|
|
|
|
@pytest.mark.parametrize("tag", ("div", "table", "custom-element"))
|
|
def test_html_block_suppresses_markdown_until_blank_line(tag: str) -> None:
|
|
links, issues = documentation_integrity._parse_markdown_links(
|
|
f"<{tag}>\n[fake](missing-inside.md)\n</{tag}>\n\n"
|
|
"[real](README.md)\n",
|
|
source="README.md",
|
|
)
|
|
|
|
assert [link.target for link in links] == ["README.md"]
|
|
assert issues == ()
|
|
|
|
|
|
def test_raw_html_link_policy_remains_active_inside_html_block() -> None:
|
|
links, issues = documentation_integrity._parse_markdown_links(
|
|
'<div>\n<a href="missing.md">x</a>\n</div>\n',
|
|
source="README.md",
|
|
)
|
|
|
|
assert links == ()
|
|
assert [issue.code for issue in issues] == ["UNSUPPORTED_HTML_LINK"]
|
|
|
|
|
|
def test_malformed_definition_keeps_following_definition_in_paragraph() -> None:
|
|
links, issues = documentation_integrity._parse_markdown_links(
|
|
"[bad]: README.md trailing\n"
|
|
"[next]: missing.md\n"
|
|
"[use][next]\n",
|
|
source="README.md",
|
|
)
|
|
|
|
assert links == ()
|
|
assert [issue.code for issue in issues] == [
|
|
"MALFORMED_REFERENCE",
|
|
"UNDEFINED_REFERENCE",
|
|
]
|
|
|
|
|
|
def test_blank_line_after_malformed_definition_allows_next_definition() -> None:
|
|
links, issues = documentation_integrity._parse_markdown_links(
|
|
"[bad]: README.md trailing\n\n"
|
|
"[next]: missing.md\n"
|
|
"[use][next]\n",
|
|
source="README.md",
|
|
)
|
|
|
|
assert [link.target for link in links] == ["missing.md", "missing.md"]
|
|
assert [issue.code for issue in issues] == ["MALFORMED_REFERENCE"]
|
|
|
|
|
|
@pytest.mark.parametrize("label", (" ", "\t", "a" * 1_000))
|
|
def test_invalid_reference_definition_label_is_not_registered(
|
|
label: str,
|
|
) -> None:
|
|
links, issues = documentation_integrity._parse_markdown_links(
|
|
f"[{label}]: missing.md\n[{label}]\n",
|
|
source="README.md",
|
|
)
|
|
|
|
assert links == ()
|
|
assert [issue.code for issue in issues] == ["MALFORMED_REFERENCE"]
|
|
|
|
|
|
def test_long_link_text_preserves_full_reference_image_identity() -> None:
|
|
links, issues = documentation_integrity._parse_markdown_links(
|
|
"[id]: docs\n\n![" + "x" * 1_000 + "][id]\n",
|
|
source="README.md",
|
|
)
|
|
|
|
assert [link.target for link in links] == ["docs", "docs"]
|
|
assert [link.is_image for link in links] == [False, True]
|
|
assert issues == ()
|
|
|
|
|
|
@pytest.mark.parametrize("fragment", ("[x](<", '[x](a "'))
|
|
def test_closed_nested_malformed_arguments_have_bounded_scan_work(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
fragment: str,
|
|
) -> None:
|
|
source = fragment * 8_000 + "z" + ")" * 8_000
|
|
original_split = documentation_integrity._split_destination_and_title
|
|
scanned_characters = 0
|
|
|
|
def recording_split(value: str) -> tuple[str | None, str | None]:
|
|
nonlocal scanned_characters
|
|
scanned_characters += len(value)
|
|
return original_split(value)
|
|
|
|
monkeypatch.setattr(
|
|
documentation_integrity,
|
|
"_split_destination_and_title",
|
|
recording_split,
|
|
)
|
|
|
|
documentation_integrity._parse_markdown_links(
|
|
source,
|
|
source="README.md",
|
|
)
|
|
|
|
assert scanned_characters <= len(source) * 100
|
|
|
|
|
|
def test_nested_parenthesized_titles_have_bounded_scan_work(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
source = "[x](a (" * 8_000 + "z" + ")" * 16_000
|
|
original_split = documentation_integrity._split_destination_and_title
|
|
scanned_characters = 0
|
|
|
|
def recording_split(value: str) -> tuple[str | None, str | None]:
|
|
nonlocal scanned_characters
|
|
scanned_characters += len(value)
|
|
return original_split(value)
|
|
|
|
monkeypatch.setattr(
|
|
documentation_integrity,
|
|
"_split_destination_and_title",
|
|
recording_split,
|
|
)
|
|
|
|
documentation_integrity._parse_markdown_links(
|
|
source,
|
|
source="README.md",
|
|
)
|
|
|
|
assert scanned_characters <= len(source) * 100
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"content",
|
|
(
|
|
'prefix <?x value="[fake](missing.md) <a href=x>"?> suffix\n',
|
|
'prefix <!DOCTYPE x "[fake](missing.md) <a href=x>"> suffix\n',
|
|
'prefix < <a href=x>]]> suffix\n',
|
|
),
|
|
)
|
|
def test_inline_special_html_constructs_are_opaque(content: str) -> None:
|
|
links, issues = documentation_integrity._parse_markdown_links(
|
|
content,
|
|
source="README.md",
|
|
)
|
|
|
|
assert links == ()
|
|
assert issues == ()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"prefix",
|
|
(
|
|
"prefix <!-- broken ",
|
|
"prefix <?broken ",
|
|
"prefix <\n",
|
|
source="README.md",
|
|
)
|
|
|
|
assert [link.target for link in links] == ["missing.md"]
|
|
assert issues == ()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"hidden",
|
|
(
|
|
'<!-- <a href="missing.md">x</a> -->',
|
|
'<![CDATA[<a href="missing.md">x</a>]]>',
|
|
'<?x value="<a href=missing.md>">',
|
|
'<script>const x = "<a href=missing.md>";</script>',
|
|
),
|
|
)
|
|
def test_non_rendered_html_inside_block_does_not_trigger_policy(
|
|
hidden: str,
|
|
) -> None:
|
|
links, issues = documentation_integrity._parse_markdown_links(
|
|
f"<div>\n{hidden}\n</div>\n",
|
|
source="README.md",
|
|
)
|
|
|
|
assert links == ()
|
|
assert issues == ()
|
|
|
|
|
|
def test_pre_block_still_rejects_rendered_raw_html_link() -> None:
|
|
links, issues = documentation_integrity._parse_markdown_links(
|
|
'<pre>\n<a href="missing.md">x</a>\n</pre>\n',
|
|
source="README.md",
|
|
)
|
|
|
|
assert links == ()
|
|
assert [issue.code for issue in issues] == ["UNSUPPORTED_HTML_LINK"]
|
|
|
|
|
|
@pytest.mark.parametrize("prefix", ("<span>", "<img alt=x> "))
|
|
def test_inline_html_in_table_cell_does_not_break_table_boundary(
|
|
prefix: str,
|
|
) -> None:
|
|
links, issues = documentation_integrity._parse_markdown_links(
|
|
"A | B\n---|---\n"
|
|
f"{prefix}[not | a-link](missing.md)</span>\n",
|
|
source="README.md",
|
|
)
|
|
|
|
assert links == ()
|
|
assert issues == ()
|
|
|
|
|
|
@pytest.mark.parametrize("ticks", ("`", "``"))
|
|
def test_backticks_inside_raw_html_attributes_do_not_open_code_span(
|
|
ticks: str,
|
|
) -> None:
|
|
links, issues = documentation_integrity._parse_markdown_links(
|
|
f'<span title="{ticks}">[real](missing.md)'
|
|
f'<span title="{ticks}">{ticks}\n',
|
|
source="README.md",
|
|
)
|
|
|
|
assert [link.target for link in links] == ["missing.md"]
|
|
assert issues == ()
|
|
|
|
|
|
@pytest.mark.parametrize("ticks", ("`", "``"))
|
|
def test_backticks_inside_autolinks_do_not_open_code_span(
|
|
ticks: str,
|
|
) -> None:
|
|
links, issues = documentation_integrity._parse_markdown_links(
|
|
f"<https://example.test/{ticks}>[real](missing.md)"
|
|
f"<https://example.test/{ticks}>{ticks}\n",
|
|
source="README.md",
|
|
)
|
|
|
|
assert "missing.md" in {link.target for link in links}
|
|
assert issues == ()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"would_be_token",
|
|
(
|
|
'<a href="`">',
|
|
"<https://example.test/`>",
|
|
),
|
|
)
|
|
def test_earlier_backtick_takes_precedence_over_html_token(
|
|
would_be_token: str,
|
|
) -> None:
|
|
links, issues = documentation_integrity._parse_markdown_links(
|
|
f"`{would_be_token}[real](missing.md)`\n",
|
|
source="README.md",
|
|
)
|
|
|
|
assert [link.target for link in links] == ["missing.md"]
|
|
assert issues == ()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("opening", "closing"),
|
|
(
|
|
("`foo\\", "`"),
|
|
("``foo\\", "``"),
|
|
),
|
|
)
|
|
def test_backslash_does_not_escape_code_span_closer(
|
|
opening: str,
|
|
closing: str,
|
|
) -> None:
|
|
links, issues = documentation_integrity._parse_markdown_links(
|
|
f"{opening}{closing} [real](missing.md) {closing}\n",
|
|
source="README.md",
|
|
)
|
|
|
|
assert [link.target for link in links] == ["missing.md"]
|
|
assert issues == ()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"argument",
|
|
(
|
|
"foo`bar`baz.md",
|
|
"<foo`bar`baz.md>",
|
|
),
|
|
)
|
|
def test_backticks_inside_link_destination_are_literal(argument: str) -> None:
|
|
links, issues = documentation_integrity._parse_markdown_links(
|
|
f"[link]({argument})\n",
|
|
source="README.md",
|
|
)
|
|
|
|
assert [link.target for link in links] == ["foo`bar`baz.md"]
|
|
assert issues == ()
|
|
|
|
|
|
def test_angle_inline_destination_may_contain_spaces(tmp_path: Path) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
target = repository_root / "docs" / "my file.md"
|
|
target.write_text("space\n", encoding="utf-8")
|
|
_write_readme(repository_root, "[link](<docs/my file.md>)\n")
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 0, _combined_output(result)
|
|
|
|
|
|
def test_angle_destination_preserves_significant_trailing_space(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
(repository_root / "docs" / "foo.md").write_text(
|
|
"without space\n",
|
|
encoding="utf-8",
|
|
)
|
|
_write_readme(repository_root, "[link](<docs/foo.md >)\n")
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert "MISSING_TARGET" in result.stderr
|
|
|
|
|
|
def test_angle_destination_can_resolve_filename_with_trailing_space(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
(repository_root / "docs" / "foo.md ").write_text(
|
|
"with space\n",
|
|
encoding="utf-8",
|
|
)
|
|
_write_readme(repository_root, "[link](<docs/foo.md >)\n")
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 0, _combined_output(result)
|
|
|
|
|
|
def test_angle_destination_preserves_significant_leading_space(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(repository_root, "[link](< README.md>)\n")
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert "MISSING_TARGET" in result.stderr
|
|
|
|
|
|
def test_angle_destination_can_resolve_filename_with_leading_space(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
(repository_root / " leading.md").write_text(
|
|
"with space\n",
|
|
encoding="utf-8",
|
|
)
|
|
_write_readme(repository_root, "[link](< leading.md>)\n")
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 0, _combined_output(result)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"identifier",
|
|
(
|
|
"<a href=x>",
|
|
"<https://identifier.example.test/value>",
|
|
"<file:///identifier>",
|
|
),
|
|
)
|
|
def test_reference_identifiers_are_not_rendered_as_html_or_autolinks(
|
|
identifier: str,
|
|
) -> None:
|
|
links, issues = documentation_integrity._parse_markdown_links(
|
|
f"[{identifier}]: README.md\n\n[use][{identifier}]\n",
|
|
source="README.md",
|
|
)
|
|
|
|
assert [link.target for link in links] == ["README.md", "README.md"]
|
|
assert issues == ()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"identifier",
|
|
(
|
|
"`id`",
|
|
"<span>id</span>",
|
|
),
|
|
)
|
|
def test_reference_labels_are_matched_as_literal_identifiers(
|
|
identifier: str,
|
|
) -> None:
|
|
links, issues = documentation_integrity._parse_markdown_links(
|
|
f"[{identifier}]: README.md\n\n[use][{identifier}]\n",
|
|
source="README.md",
|
|
)
|
|
|
|
assert [link.target for link in links] == ["README.md", "README.md"]
|
|
assert issues == ()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("identifier", "trailing"),
|
|
(
|
|
("`id", " later `"),
|
|
("<https://id", ">"),
|
|
("<span title=x", ">"),
|
|
),
|
|
)
|
|
def test_resolved_full_reference_suffix_precedes_inline_tokens(
|
|
identifier: str,
|
|
trailing: str,
|
|
) -> None:
|
|
links, issues = documentation_integrity._parse_markdown_links(
|
|
f"[{identifier}]: README.md\n\n[use][{identifier}]{trailing}\n",
|
|
source="README.md",
|
|
)
|
|
|
|
assert [link.target for link in links] == ["README.md", "README.md"]
|
|
assert issues == ()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("definition_label", "usage_label"),
|
|
(
|
|
(r"foo\!", "foo!"),
|
|
("a&b", "a&b"),
|
|
("a&b", "a&b"),
|
|
("a\N{NO-BREAK SPACE}b", "a b"),
|
|
("a\fb", "a b"),
|
|
),
|
|
)
|
|
def test_reference_labels_match_normalised_source_not_inline_content(
|
|
definition_label: str,
|
|
usage_label: str,
|
|
) -> None:
|
|
links, issues = documentation_integrity._parse_markdown_links(
|
|
f"[{definition_label}]: README.md\n\n[use][{usage_label}]\n",
|
|
source="README.md",
|
|
)
|
|
|
|
assert [link.target for link in links] == ["README.md"]
|
|
assert [issue.code for issue in issues] == ["UNDEFINED_REFERENCE"]
|
|
|
|
|
|
def test_unescaped_nested_bracket_does_not_form_full_reference_label() -> None:
|
|
links, issues = documentation_integrity._parse_markdown_links(
|
|
r"[ref\[bar\]]: missing.md" + "\n\n[use][ref[bar]]\n",
|
|
source="README.md",
|
|
)
|
|
|
|
assert [link.target for link in links] == ["missing.md"]
|
|
assert issues == ()
|
|
|
|
|
|
def test_escaped_brackets_form_full_reference_label() -> None:
|
|
links, issues = documentation_integrity._parse_markdown_links(
|
|
r"[ref\[bar\]]: missing.md"
|
|
+ "\n\n"
|
|
+ r"[use][ref\[bar\]]"
|
|
+ "\n",
|
|
source="README.md",
|
|
)
|
|
|
|
assert [link.target for link in links] == ["missing.md", "missing.md"]
|
|
assert issues == ()
|
|
|
|
|
|
def test_successful_later_reference_consumes_earlier_suffix() -> None:
|
|
links, issues = documentation_integrity._parse_markdown_links(
|
|
"[baz]: baz.md\n\n[foo][bar][baz]\n",
|
|
source="README.md",
|
|
)
|
|
|
|
assert [link.target for link in links] == ["baz.md", "baz.md"]
|
|
assert issues == ()
|
|
|
|
|
|
def test_selected_outer_reference_leaves_trailing_shortcut_available() -> None:
|
|
links, issues = documentation_integrity._parse_markdown_links(
|
|
"[bar]: bar.md\n[baz]: baz.md\n\n[foo][bar][baz]\n",
|
|
source="README.md",
|
|
)
|
|
|
|
assert [link.target for link in links] == [
|
|
"bar.md",
|
|
"baz.md",
|
|
"bar.md",
|
|
"baz.md",
|
|
]
|
|
assert issues == ()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("body", "expected_usages"),
|
|
(
|
|
("[[x][bar]][baz]", (("bar.md", False), ("baz.md", False))),
|
|
("![[x][bar]][baz]", (("baz.md", True), ("bar.md", False))),
|
|
("[![x][bar]][baz]", (("baz.md", False), ("bar.md", True))),
|
|
),
|
|
)
|
|
def test_nested_reference_precedence_preserves_actual_targets(
|
|
body: str,
|
|
expected_usages: tuple[tuple[str, bool], ...],
|
|
) -> None:
|
|
links, issues = documentation_integrity._parse_markdown_links(
|
|
f"[bar]: bar.md\n[baz]: baz.md\n\n{body}\n",
|
|
source="README.md",
|
|
)
|
|
|
|
assert [(link.target, link.is_image) for link in links[2:]] == list(
|
|
expected_usages
|
|
)
|
|
assert issues == ()
|
|
|
|
|
|
def test_successful_inline_link_consumes_unresolved_outer_suffix() -> None:
|
|
links, issues = documentation_integrity._parse_markdown_links(
|
|
"[foo][bar](missing.md)\n",
|
|
source="README.md",
|
|
)
|
|
|
|
assert [link.target for link in links] == ["missing.md"]
|
|
assert issues == ()
|
|
|
|
|
|
def test_unresolved_full_reference_does_not_fallback_to_shortcut() -> None:
|
|
links, issues = documentation_integrity._parse_markdown_links(
|
|
"[foo]: foo.md\n\n[foo][bar]\n",
|
|
source="README.md",
|
|
)
|
|
|
|
assert [link.target for link in links] == ["foo.md"]
|
|
assert [issue.code for issue in issues] == ["UNDEFINED_REFERENCE"]
|
|
|
|
|
|
def test_unoverlapped_full_reference_remains_strictly_undefined() -> None:
|
|
links, issues = documentation_integrity._parse_markdown_links(
|
|
"[foo][missing]\n",
|
|
source="README.md",
|
|
)
|
|
|
|
assert links == ()
|
|
assert [issue.code for issue in issues] == ["UNDEFINED_REFERENCE"]
|
|
|
|
|
|
def test_raw_html_in_rendered_reference_text_is_still_rejected() -> None:
|
|
links, issues = documentation_integrity._parse_markdown_links(
|
|
"[label]: README.md\n\n[<a href=x>][label]\n",
|
|
source="README.md",
|
|
)
|
|
|
|
assert [link.target for link in links] == ["README.md", "README.md"]
|
|
assert [issue.code for issue in issues] == ["UNSUPPORTED_HTML_LINK"]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"destination",
|
|
(
|
|
"foo)bar.md",
|
|
"(" * 33 + "target.md" + ")" * 33,
|
|
),
|
|
)
|
|
def test_reference_definition_rejects_invalid_commonmark_destination(
|
|
destination: str,
|
|
) -> None:
|
|
links, issues = documentation_integrity._parse_markdown_links(
|
|
f"[identifier]: {destination}\n\n[identifier]\n",
|
|
source="README.md",
|
|
)
|
|
|
|
assert links == ()
|
|
assert "MALFORMED_REFERENCE" in {issue.code for issue in issues}
|
|
|
|
|
|
def test_reference_definition_angle_destination_may_contain_spaces() -> None:
|
|
links, issues = documentation_integrity._parse_markdown_links(
|
|
"[identifier]: <path with spaces.md>\n\n[identifier]\n",
|
|
source="README.md",
|
|
)
|
|
|
|
assert [link.target for link in links] == [
|
|
"path with spaces.md",
|
|
"path with spaces.md",
|
|
]
|
|
assert issues == ()
|
|
|
|
|
|
def test_reference_definition_accepts_escaped_closing_parenthesis() -> None:
|
|
links, issues = documentation_integrity._parse_markdown_links(
|
|
r"[identifier]: foo\)bar.md" "\n\n[identifier]\n",
|
|
source="README.md",
|
|
)
|
|
|
|
assert [link.target for link in links] == [r"foo\)bar.md", r"foo\)bar.md"]
|
|
assert issues == ()
|
|
|
|
|
|
def test_multiline_reference_definition_label_is_supported() -> None:
|
|
links, issues = documentation_integrity._parse_markdown_links(
|
|
"[foo\n bar]: README.md\n\n[use][foo bar]\n",
|
|
source="README.md",
|
|
)
|
|
|
|
assert [link.target for link in links] == ["README.md", "README.md"]
|
|
assert issues == ()
|
|
|
|
|
|
def test_reference_destination_may_start_after_one_line_ending() -> None:
|
|
links, issues = documentation_integrity._parse_markdown_links(
|
|
"[foo]:\n /url\n\n[foo]\n",
|
|
source="README.md",
|
|
)
|
|
|
|
assert [link.target for link in links] == ["/url", "/url"]
|
|
assert issues == ()
|
|
|
|
|
|
def test_indented_reference_destination_after_line_ending_is_supported() -> None:
|
|
links, issues = documentation_integrity._parse_markdown_links(
|
|
" [foo]:\n /url\n 'the title'\n\n[foo]\n",
|
|
source="README.md",
|
|
)
|
|
|
|
assert [link.target for link in links] == ["/url", "/url"]
|
|
assert issues == ()
|
|
|
|
|
|
def test_angle_reference_destination_may_contain_spaces() -> None:
|
|
links, issues = documentation_integrity._parse_markdown_links(
|
|
"[Foo bar]:\n<my url>\n'title'\n\n[Foo bar]\n",
|
|
source="README.md",
|
|
)
|
|
|
|
assert [link.target for link in links] == ["my url", "my url"]
|
|
assert issues == ()
|
|
|
|
|
|
def test_reference_title_may_contain_one_line_ending() -> None:
|
|
links, issues = documentation_integrity._parse_markdown_links(
|
|
'[foo]: /url "first\nsecond"\n\n[foo]\n',
|
|
source="README.md",
|
|
)
|
|
|
|
assert [link.target for link in links] == ["/url", "/url"]
|
|
assert issues == ()
|
|
|
|
|
|
def test_reference_title_may_span_several_lines() -> None:
|
|
links, issues = documentation_integrity._parse_markdown_links(
|
|
"[foo]: /url '\n title\n line1\n line2\n '\n\n[foo]\n",
|
|
source="README.md",
|
|
)
|
|
|
|
assert [link.target for link in links] == ["/url", "/url"]
|
|
assert issues == ()
|
|
|
|
|
|
def test_bare_reference_definition_requires_destination() -> None:
|
|
links, issues = documentation_integrity._parse_markdown_links(
|
|
"[foo]:\n\n[foo]\n",
|
|
source="README.md",
|
|
)
|
|
|
|
assert links == ()
|
|
assert [issue.code for issue in issues] == ["MALFORMED_REFERENCE"]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"outer",
|
|
(
|
|
'[bad](README.md "unterminated ',
|
|
"[bad](README.md 'unterminated ",
|
|
"[bad](README.md (unterminated ",
|
|
"[bad](<unterminated",
|
|
),
|
|
)
|
|
def test_unclosed_outer_argument_recovers_inner_link(outer: str) -> None:
|
|
links, issues = documentation_integrity._parse_markdown_links(
|
|
outer + "[good](missing.md)\n",
|
|
source="README.md",
|
|
)
|
|
|
|
assert [link.target for link in links] == ["missing.md"]
|
|
assert "MALFORMED_LINK" in {issue.code for issue in issues}
|
|
|
|
|
|
def test_long_valid_inner_link_is_not_replaced_by_complexity_policy() -> None:
|
|
target = "https://example.test/" + "x" * 5_000
|
|
links, issues = documentation_integrity._parse_markdown_links(
|
|
f"[bad](< [good]({target}))\n",
|
|
source="README.md",
|
|
)
|
|
|
|
assert [link.target for link in links] == [target]
|
|
assert "MALFORMED_LINK" in {issue.code for issue in issues}
|
|
|
|
|
|
def test_encoded_reserved_characters_remain_part_of_local_filename(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
architecture = repository_root / "docs" / "architecture"
|
|
(architecture / "a#b.md").write_text("hash\n", encoding="utf-8")
|
|
(architecture / "a?b.md").write_text("query\n", encoding="utf-8")
|
|
_write_readme(
|
|
repository_root,
|
|
"[hash](docs/architecture/a%23b.md)\n"
|
|
"[query](docs/architecture/a%3Fb.md)\n",
|
|
)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 0, _combined_output(result)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"target",
|
|
(
|
|
"docs%2Farchitecture%2Foverview.md",
|
|
"docs%5Carchitecture%5Coverview.md",
|
|
"docs%2F..%2FREADME.md",
|
|
),
|
|
)
|
|
def test_encoded_path_separator_is_not_treated_as_hierarchy(
|
|
tmp_path: Path,
|
|
target: str,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(repository_root, f"[encoded]({target})\n")
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert "ENCODED_PATH_SEPARATOR" in result.stderr
|
|
|
|
|
|
def test_double_encoded_separator_remains_literal_filename(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
(repository_root / "docs" / "literal%2Fname.md").write_text(
|
|
"literal\n",
|
|
encoding="utf-8",
|
|
)
|
|
_write_readme(repository_root, "[literal](docs/literal%252Fname.md)\n")
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 0, _combined_output(result)
|
|
|
|
|
|
def test_encoded_separator_remains_valid_inside_fragment(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(
|
|
repository_root,
|
|
"[same document](#section%2Fpart)\n"
|
|
"[other document](docs/architecture/overview.md#section%2Fpart)\n",
|
|
)
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 0, _combined_output(result)
|
|
|
|
|
|
@pytest.mark.parametrize("literal_name", ("a©.md", "a¬it;.md"))
|
|
def test_unterminated_or_unknown_entities_remain_literal_in_target(
|
|
tmp_path: Path,
|
|
literal_name: str,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
target = repository_root / "docs" / literal_name
|
|
target.write_text("literal\n", encoding="utf-8")
|
|
_write_readme(repository_root, f"[literal](docs/{literal_name})\n")
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 0, _combined_output(result)
|
|
|
|
|
|
def test_commonmark_entities_are_decoded_exactly_once(tmp_path: Path) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
(repository_root / "docs" / "a©.md").write_text(
|
|
"literal entity\n",
|
|
encoding="utf-8",
|
|
)
|
|
_write_readme(repository_root, "[literal](docs/a&copy;.md)\n")
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 0, _combined_output(result)
|
|
|
|
|
|
def test_commonmark_entity_is_not_decoded_twice_to_other_filename(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
(repository_root / "docs" / "a©.md").write_text(
|
|
"copyright\n",
|
|
encoding="utf-8",
|
|
)
|
|
_write_readme(repository_root, "[literal](docs/a&copy;.md)\n")
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert "MISSING_TARGET" in result.stderr
|
|
|
|
|
|
def test_entity_produced_backslash_is_not_a_markdown_escape(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
(repository_root / "docs" / "target.md").write_text(
|
|
"target\n",
|
|
encoding="utf-8",
|
|
)
|
|
_write_readme(repository_root, "[target](docs\/target.md)\n")
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert "BACKSLASH_TARGET" in result.stderr
|
|
|
|
|
|
def test_backslash_escaped_entity_opener_remains_literal(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
(repository_root / "docs" / "©.md").write_text(
|
|
"literal\n",
|
|
encoding="utf-8",
|
|
)
|
|
_write_readme(repository_root, r"[literal](docs/\©.md)" "\n")
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 0, _combined_output(result)
|
|
|
|
|
|
@pytest.mark.parametrize("entity", ("©", "©", "©"))
|
|
def test_terminated_entities_are_decoded_in_target(
|
|
tmp_path: Path,
|
|
entity: str,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
target = repository_root / "docs" / "a©.md"
|
|
target.write_text("decoded\n", encoding="utf-8")
|
|
_write_readme(repository_root, f"[decoded](docs/a{entity}.md)\n")
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 0, _combined_output(result)
|
|
|
|
|
|
def test_discovery_source_type_probe_error_is_reported(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
source = repository_root / "docs"
|
|
original_is_file = Path.is_file
|
|
|
|
def broken_is_file(path: Path) -> bool:
|
|
if path == source:
|
|
raise PermissionError("denied")
|
|
return original_is_file(path)
|
|
|
|
monkeypatch.setattr(Path, "is_file", broken_is_file)
|
|
result = check_documentation_integrity(
|
|
repository_root,
|
|
required_documents=(),
|
|
markdown_sources=(Path("docs"),),
|
|
)
|
|
|
|
assert [issue.code for issue in result.issues] == ["SOURCE_IO_ERROR"]
|
|
|
|
|
|
def test_discovery_candidate_type_probe_error_is_reported(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
candidate = repository_root / "docs" / "architecture" / "overview.md"
|
|
original_is_file = Path.is_file
|
|
|
|
def broken_is_file(path: Path) -> bool:
|
|
if path == candidate:
|
|
raise PermissionError("denied")
|
|
return original_is_file(path)
|
|
|
|
monkeypatch.setattr(Path, "is_file", broken_is_file)
|
|
result = check_documentation_integrity(
|
|
repository_root,
|
|
required_documents=(),
|
|
markdown_sources=(Path("docs"),),
|
|
)
|
|
|
|
assert "SOURCE_IO_ERROR" in {issue.code for issue in result.issues}
|
|
|
|
|
|
def test_manifest_type_probe_error_is_reported(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
manifest = repository_root / "README.md"
|
|
original_is_file = Path.is_file
|
|
|
|
def broken_is_file(path: Path) -> bool:
|
|
if path == manifest:
|
|
raise PermissionError("denied")
|
|
return original_is_file(path)
|
|
|
|
monkeypatch.setattr(Path, "is_file", broken_is_file)
|
|
result = check_documentation_integrity(
|
|
repository_root,
|
|
required_documents=("README.md",),
|
|
markdown_sources=(),
|
|
)
|
|
|
|
assert [issue.code for issue in result.issues] == ["MANIFEST_IO_ERROR"]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("target", "expected_code"),
|
|
(
|
|
("docs/%ZZ/overview.md", "INVALID_PERCENT_ESCAPE"),
|
|
("docs/architecture/over\x00view.md", "CONTROL_CHARACTER"),
|
|
("docs/%09architecture/overview.md", "CONTROL_CHARACTER"),
|
|
("docs/%0Aarchitecture/overview.md", "CONTROL_CHARACTER"),
|
|
("docs/\x85architecture/overview.md", "CONTROL_CHARACTER"),
|
|
("docs/%C2%85architecture/overview.md", "CONTROL_CHARACTER"),
|
|
),
|
|
)
|
|
def test_malformed_encoded_and_control_targets_are_rejected(
|
|
tmp_path: Path,
|
|
target: str,
|
|
expected_code: str,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(repository_root, f"[invalid]({target})\n")
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert expected_code in result.stderr
|
|
|
|
|
|
def test_external_links_are_not_fetched(tmp_path: Path) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
_write_readme(
|
|
repository_root,
|
|
"[https](https://unresolvable.invalid/document)\n"
|
|
"[mail](mailto:nobody@unresolvable.invalid)\n"
|
|
"<wss://unresolvable.invalid/socket>\n",
|
|
)
|
|
environment = dict(os.environ)
|
|
environment.update(
|
|
{
|
|
"HTTP_PROXY": "http://127.0.0.1:1",
|
|
"HTTPS_PROXY": "http://127.0.0.1:1",
|
|
"ALL_PROXY": "http://127.0.0.1:1",
|
|
}
|
|
)
|
|
|
|
result = _run_gate(repository_root, environment=environment)
|
|
|
|
assert result.returncode == 0, _combined_output(result)
|
|
|
|
|
|
def test_invalid_utf8_document_is_reported(tmp_path: Path) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
invalid = repository_root / "docs" / "invalid.md"
|
|
invalid.write_bytes(b"\xff\xfe")
|
|
|
|
result = _run_gate(repository_root)
|
|
|
|
assert result.returncode == 1
|
|
assert "INVALID_UTF8" in result.stderr
|
|
|
|
|
|
def test_multiple_diagnostics_have_deterministic_path_and_line_order(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
repository_root = _make_repository(tmp_path)
|
|
first = repository_root / "docs" / "a.md"
|
|
second = repository_root / "docs" / "z.md"
|
|
first.write_text("\n[missing](missing-a.md)\n", encoding="utf-8")
|
|
second.write_text("[missing](missing-z.md)\n", encoding="utf-8")
|
|
|
|
first_run = _run_gate(repository_root)
|
|
second_run = _run_gate(repository_root)
|
|
|
|
assert first_run.returncode == 1
|
|
assert first_run.stderr == second_run.stderr
|
|
assert first_run.stderr.index("docs/a.md:2") < first_run.stderr.index("docs/z.md:1")
|
|
|
|
|
|
def test_invalid_repository_root_returns_cli_usage_error(tmp_path: Path) -> None:
|
|
result = _run_gate(tmp_path / "missing")
|
|
|
|
assert result.returncode == 2
|
|
assert "Repository root не существует" in result.stderr
|
|
|
|
|
|
def test_symlink_loop_repository_root_returns_cli_usage_error(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
loop = tmp_path / "loop"
|
|
loop.symlink_to("loop")
|
|
|
|
result = _run_gate(loop)
|
|
|
|
assert result.returncode == 2
|
|
assert "Repository root не существует" in result.stderr
|
|
assert "Traceback" not in result.stderr
|
|
|
|
|
|
def test_unresolvable_home_repository_root_returns_cli_usage_error() -> None:
|
|
result = _run_gate(Path("~dzentra_documentation_gate_missing_user"))
|
|
|
|
assert result.returncode == 2
|
|
assert "Repository root не существует" in result.stderr
|
|
assert "Traceback" not in result.stderr
|