Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions docuchango/validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
from enum import Enum
from pathlib import Path
from typing import Any, cast
from urllib.parse import unquote

try:
import frontmatter
Expand Down Expand Up @@ -254,6 +255,11 @@ def log(self, message: str, force: bool = False):
if self.verbose or force:
print(message)

@staticmethod
def _link_path_target(target: str) -> str:
"""Return the filesystem path portion of a Markdown link target."""
return unquote(re.split(r"[?#]", target, maxsplit=1)[0])

def _get_folder_config(self) -> dict[str, str]:
"""Get folder configuration from project config or use defaults"""
if self.project_config and self.project_config.structure:
Expand Down Expand Up @@ -662,14 +668,14 @@ def validate_links(self):

def _validate_internal_link(self, link: Link):
"""Validate internal document link"""
target = link.target.split("#")[0] # Remove anchor
target = self._link_path_target(link.target)

# Handle relative paths
if target.startswith(("./", "../")):
source_dir = link.source_doc.parent
target_path = (source_dir / target).resolve()

if not target.endswith(".md"):
if not target_path.suffix:
target_path = Path(str(target_path) + ".md")

if target_path.exists():
Expand Down Expand Up @@ -857,7 +863,7 @@ def _extract_markdown_file_links(self, content: str, source_path: Path) -> dict[

line_without_code = re.sub(r"`[^`]+`", "", line)
for match in link_pattern.finditer(line_without_code):
target = match.group(2).split("#", 1)[0]
target = self._link_path_target(match.group(2))
if re.match(r"^[a-z][a-z0-9+.-]*:", target):
continue
if not target.endswith(".md") and not target.startswith(("./", "../", "/")):
Expand All @@ -867,7 +873,7 @@ def _extract_markdown_file_links(self, content: str, source_path: Path) -> dict[
resolved = (self.repo_root / target.lstrip("/")).resolve()
else:
resolved = (source_path.parent / target).resolve()
if not target.endswith(".md"):
if not resolved.suffix:
resolved = Path(str(resolved) + ".md")
links[resolved] = (line_num, target)

Expand Down
36 changes: 36 additions & 0 deletions tests/test_document_indexes.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,3 +169,39 @@ def test_milestone_document_index_uses_configured_frontmatter_field():
validator.check_document_indexes()

assert "Document index 'Plan Index' missing bucket heading: M2" in validator.errors


def test_document_index_accepts_extensionless_links_with_query_and_anchor():
"""Configured indexes should resolve clean local path targets before matching."""
with tempfile.TemporaryDirectory() as tmpdir:
repo_root = Path(tmpdir)
docs = repo_root / "docs"
decisions = docs / "decisions"
decisions.mkdir(parents=True)

(decisions / "decision-a.md").write_text("# Decision A\n")
(docs / "design-index.md").write_text(
"""# Design Index

- [Decision A](./decisions/decision-a?view=full#context)
"""
)

write_config(
repo_root,
{
"project": {"id": "index-project", "name": "Index Project"},
"indexes": [
{
"name": "Shared Design Index",
"path": "docs/design-index.md",
"targets": ["docs/decisions/*.md"],
}
],
},
)

validator = DocValidator(repo_root, verbose=False)
validator.check_document_indexes()

assert validator.errors == []
143 changes: 143 additions & 0 deletions tests/test_link_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -559,6 +559,149 @@ def test_valid_parent_directory_link(self, tmp_path):
assert len(adr_doc.links) == 1
assert adr_doc.links[0].is_valid is True

def test_valid_relative_static_asset_link(self, tmp_path):
"""Test validation of relative links to non-markdown assets."""
docs_root = tmp_path / "repo"
rfc_dir = docs_root / "docs-cms" / "rfcs"
static_dir = docs_root / "docs-cms" / "static" / "rfc-001"
rfc_dir.mkdir(parents=True)
static_dir.mkdir(parents=True)

target_file = static_dir / "architecture.png"
target_file.write_bytes(b"png")

doc_file = rfc_dir / "rfc-001-test.md"
content = """---
id: "rfc-001"
title: "Test RFC"
status: Draft
created: 2025-10-13
authors: Team
tags: ["test"]
project_id: "test-project"
doc_uuid: "8b063564-82a5-4a21-943f-e868388d36b9"
---

![Architecture](../static/rfc-001/architecture.png)
"""
doc_file.write_text(content)

validator = DocValidator(repo_root=docs_root, verbose=False)
validator.scan_documents()
validator.extract_links()
validator.validate_links()

rfc_doc = [doc for doc in validator.documents if "rfc-001" in str(doc.file_path)][0]
assert len(rfc_doc.links) == 1
assert rfc_doc.links[0].is_valid is True
assert not rfc_doc.links[0].error_message.endswith(".png.md")

def test_valid_relative_static_asset_link_with_query_and_anchor(self, tmp_path):
"""Test validation ignores query strings and anchors on asset links."""
docs_root = tmp_path / "repo"
rfc_dir = docs_root / "docs-cms" / "rfcs"
static_dir = docs_root / "docs-cms" / "static" / "rfc-001"
rfc_dir.mkdir(parents=True)
static_dir.mkdir(parents=True)

target_file = static_dir / "architecture.svg"
target_file.write_text("<svg></svg>")

doc_file = rfc_dir / "rfc-001-test.md"
content = """---
id: "rfc-001"
title: "Test RFC"
status: Draft
created: 2025-10-13
authors: Team
tags: ["test"]
project_id: "test-project"
doc_uuid: "8b063564-82a5-4a21-943f-e868388d36b9"
---

![Architecture](../static/rfc-001/architecture.svg?raw=true#diagram)
"""
doc_file.write_text(content)

validator = DocValidator(repo_root=docs_root, verbose=False)
validator.scan_documents()
validator.extract_links()
validator.validate_links()

rfc_doc = [doc for doc in validator.documents if "rfc-001" in str(doc.file_path)][0]
assert len(rfc_doc.links) == 1
assert rfc_doc.links[0].is_valid is True

def test_valid_relative_asset_link_with_url_escaped_name(self, tmp_path):
"""Test validation decodes URL-escaped local file names."""
docs_root = tmp_path / "repo"
rfc_dir = docs_root / "docs-cms" / "rfcs"
static_dir = docs_root / "docs-cms" / "static" / "rfc-001"
rfc_dir.mkdir(parents=True)
static_dir.mkdir(parents=True)

target_file = static_dir / "expanded architecture.png"
target_file.write_bytes(b"png")

doc_file = rfc_dir / "rfc-001-test.md"
content = """---
id: "rfc-001"
title: "Test RFC"
status: Draft
created: 2025-10-13
authors: Team
tags: ["test"]
project_id: "test-project"
doc_uuid: "8b063564-82a5-4a21-943f-e868388d36b9"
---

![Architecture](../static/rfc-001/expanded%20architecture.png)
"""
doc_file.write_text(content)

validator = DocValidator(repo_root=docs_root, verbose=False)
validator.scan_documents()
validator.extract_links()
validator.validate_links()

rfc_doc = [doc for doc in validator.documents if "rfc-001" in str(doc.file_path)][0]
assert len(rfc_doc.links) == 1
assert rfc_doc.links[0].is_valid is True

def test_valid_extensionless_relative_internal_link(self, tmp_path):
"""Test extensionless document links still fall back to .md."""
docs_root = tmp_path / "repo"
doc_dir = docs_root / "docs-cms" / "adr"
doc_dir.mkdir(parents=True)

target_file = doc_dir / "adr-002-target.md"
target_file.write_text("# Target\n")

doc_file = doc_dir / "adr-001-test.md"
content = """---
id: "adr-001"
title: "Test Decision"
status: Accepted
date: 2025-10-13
deciders: Team
tags: ["test"]
project_id: "test-project"
doc_uuid: "8b063564-82a5-4a21-943f-e868388d36b9"
---

See [ADR 002](./adr-002-target#context) for details.
"""
doc_file.write_text(content)

validator = DocValidator(repo_root=docs_root, verbose=False)
validator.scan_documents()
validator.extract_links()
validator.validate_links()

adr_doc = [doc for doc in validator.documents if "adr-001" in str(doc.file_path)][0]
assert len(adr_doc.links) == 1
assert adr_doc.links[0].is_valid is True

def test_link_with_anchor_validates_base_file(self, tmp_path):
"""Test that links with anchors validate the base file."""
docs_root = tmp_path / "repo"
Expand Down