From e8cbe8b88710807003da7e3e4865b4bc9fe6aad1 Mon Sep 17 00:00:00 2001 From: hotragn Date: Tue, 18 Aug 2026 16:31:31 -0400 Subject: [PATCH 1/2] fix(wiki): skip uppercase markdown suffixes so a .MD file cannot break every query `iter_page_paths` globs `rglob("*.md")`, which is case-insensitive on Windows and on default macOS volumes, so it also matches "NOTES.MD" and "Mixed.Md". `page_id_for_path` then compares the suffix exactly and raises `ValidationFailed("wiki page must be markdown")`. `load_page_document` calls `page_id_for_path` unguarded, so the error propagates through `load_documents` -> `load_index_sources` -> the implicit reindex that every query command performs. One uppercase-suffixed file anywhere under `almanac/` therefore takes down `search`, `show`, `health` and `reindex`: $ codealmanac search notes codealmanac: wiki page must be markdown: ...\almanac\NOTES.MD This is not Windows-only. macOS is case-insensitive by default, so it reproduces on the one platform the project currently supports. Linux never matched these files, which is why CI is green. The producer now yields only exact ".md" suffixes, so all three platforms derive the same page set from the same wiki tree. Fixing `iter_page_paths` rather than each caller covers the index, health and frontmatter-rewrite paths at once. Whether ".MD" should instead be *accepted* as a page is a product call, not a bug fix, so this change keeps the Linux behaviour of ignoring it. Happy to follow up if you would rather canonicalize the suffix the way slugs already are. --- src/codealmanac/services/wiki/paths.py | 7 +++++++ tests/test_wiki_parsing.py | 22 ++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/src/codealmanac/services/wiki/paths.py b/src/codealmanac/services/wiki/paths.py index 4fe21863..444ba2ce 100644 --- a/src/codealmanac/services/wiki/paths.py +++ b/src/codealmanac/services/wiki/paths.py @@ -52,6 +52,13 @@ def iter_page_paths(almanac_path: Path) -> Iterator[Path]: if not almanac_path.is_dir(): return for path in sorted(almanac_path.rglob("*.md")): + # `rglob` is case-insensitive on Windows and on default macOS volumes, so + # it also matches ".MD" and ".Md". `page_id_for_path` compares the suffix + # exactly, so yielding one of those raises and takes down every command + # that reindexes. Linux never matched them at all, so skipping here is + # what makes the three platforms agree on the same wiki tree. + if path.suffix != ".md": + continue if is_reserved_page_path(almanac_path, path): continue yield path diff --git a/tests/test_wiki_parsing.py b/tests/test_wiki_parsing.py index 097f20a3..795b21de 100644 --- a/tests/test_wiki_parsing.py +++ b/tests/test_wiki_parsing.py @@ -4,6 +4,7 @@ escape_glob_meta, iter_page_paths, normalize_reference_path, + page_id_for_path, ) @@ -19,6 +20,27 @@ def test_page_iteration_excludes_repository_manuals(tmp_path): assert tuple(iter_page_paths(almanac_path)) == (page,) +def test_page_iteration_excludes_uppercase_markdown_suffixes(tmp_path): + almanac_path = tmp_path / "almanac" + almanac_path.mkdir(parents=True) + page = almanac_path / "wiki.md" + page.write_text("# Wiki\n", encoding="utf-8") + (almanac_path / "NOTES.MD").write_text("# Notes\n", encoding="utf-8") + (almanac_path / "Mixed.Md").write_text("# Mixed\n", encoding="utf-8") + + assert tuple(iter_page_paths(almanac_path)) == (page,) + + +def test_page_ids_resolve_for_every_iterated_page(tmp_path): + almanac_path = tmp_path / "almanac" + almanac_path.mkdir(parents=True) + (almanac_path / "wiki.md").write_text("# Wiki\n", encoding="utf-8") + (almanac_path / "NOTES.MD").write_text("# Notes\n", encoding="utf-8") + + for path in iter_page_paths(almanac_path): + assert page_id_for_path(almanac_path, path) + + def test_frontmatter_uses_pydantic_validated_shape(): parsed = parse_frontmatter( """--- From 4474b050dc9ee2e3540691a9f7a83061524d83e7 Mon Sep 17 00:00:00 2001 From: hotragn Date: Tue, 18 Aug 2026 21:59:07 -0400 Subject: [PATCH 2/2] test(wiki): pin the suffix filter on case-sensitive runners too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two tests added in the previous commit only reach the new filter on a case-insensitive filesystem. On Linux `rglob("*.md")` never returns "NOTES.MD", so both assertions held without the guard ever executing — meaning the fix was untested on the only platform CI runs. Replaces the weaker of the two with one that feeds the case-insensitive match in directly, so it exercises the filter regardless of the host filesystem. Verified by removing the guard: the new test fails, and it fails for the right reason rather than because of the platform it happens to run on. --- tests/test_wiki_parsing.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/tests/test_wiki_parsing.py b/tests/test_wiki_parsing.py index 795b21de..4bc114f4 100644 --- a/tests/test_wiki_parsing.py +++ b/tests/test_wiki_parsing.py @@ -1,3 +1,5 @@ +from pathlib import Path + from codealmanac.services.wiki.frontmatter import parse_frontmatter from codealmanac.services.wiki.links import extract_page_links, resolve_page_href from codealmanac.services.wiki.paths import ( @@ -31,13 +33,23 @@ def test_page_iteration_excludes_uppercase_markdown_suffixes(tmp_path): assert tuple(iter_page_paths(almanac_path)) == (page,) -def test_page_ids_resolve_for_every_iterated_page(tmp_path): +def test_page_iteration_filters_case_insensitive_glob_matches(tmp_path, monkeypatch): + # `rglob` only returns uppercase suffixes on a case-insensitive filesystem, + # so on Linux the test above cannot reach the filter at all. Feeding the + # match in directly pins the behaviour on every runner, and keeps + # `iter_page_paths` and `page_id_for_path` provably in agreement. almanac_path = tmp_path / "almanac" almanac_path.mkdir(parents=True) - (almanac_path / "wiki.md").write_text("# Wiki\n", encoding="utf-8") - (almanac_path / "NOTES.MD").write_text("# Notes\n", encoding="utf-8") + page = almanac_path / "wiki.md" + page.write_text("# Wiki\n", encoding="utf-8") + upper = almanac_path / "NOTES.MD" + upper.write_text("# Notes\n", encoding="utf-8") + monkeypatch.setattr(Path, "rglob", lambda self, pattern: iter((page, upper))) + + iterated = tuple(iter_page_paths(almanac_path)) - for path in iter_page_paths(almanac_path): + assert iterated == (page,) + for path in iterated: assert page_id_for_path(almanac_path, path)