From 362c7ec4c543731ea913d8ee35d97e598bb8298e Mon Sep 17 00:00:00 2001 From: Jacob Repp Date: Wed, 27 May 2026 21:45:15 -0700 Subject: [PATCH 1/2] fix: preserve static asset link extensions --- docuchango/validator.py | 2 +- tests/test_link_validation.py | 37 +++++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/docuchango/validator.py b/docuchango/validator.py index e6aa7a8..c2b8f9d 100755 --- a/docuchango/validator.py +++ b/docuchango/validator.py @@ -669,7 +669,7 @@ def _validate_internal_link(self, link: Link): 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(): diff --git a/tests/test_link_validation.py b/tests/test_link_validation.py index 5a572d9..52368d6 100644 --- a/tests/test_link_validation.py +++ b/tests/test_link_validation.py @@ -559,6 +559,43 @@ 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_link_with_anchor_validates_base_file(self, tmp_path): """Test that links with anchors validate the base file.""" docs_root = tmp_path / "repo" From 918abf2d4794ffbcb8ea46b8971d67935faee0e2 Mon Sep 17 00:00:00 2001 From: Jacob Repp Date: Wed, 27 May 2026 21:47:22 -0700 Subject: [PATCH 2/2] test: cover local link target edge cases --- docuchango/validator.py | 12 +++- tests/test_document_indexes.py | 36 +++++++++++ tests/test_link_validation.py | 106 +++++++++++++++++++++++++++++++++ 3 files changed, 151 insertions(+), 3 deletions(-) diff --git a/docuchango/validator.py b/docuchango/validator.py index c2b8f9d..2d8e99b 100755 --- a/docuchango/validator.py +++ b/docuchango/validator.py @@ -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 @@ -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: @@ -662,7 +668,7 @@ 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(("./", "../")): @@ -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(("./", "../", "/")): @@ -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) diff --git a/tests/test_document_indexes.py b/tests/test_document_indexes.py index 2b1a14c..8afb72c 100644 --- a/tests/test_document_indexes.py +++ b/tests/test_document_indexes.py @@ -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 == [] diff --git a/tests/test_link_validation.py b/tests/test_link_validation.py index 52368d6..d023307 100644 --- a/tests/test_link_validation.py +++ b/tests/test_link_validation.py @@ -596,6 +596,112 @@ def test_valid_relative_static_asset_link(self, tmp_path): 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("") + + 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"