From 83a5822e24acf012d15b6bc75c275f27c8cb5c28 Mon Sep 17 00:00:00 2001 From: tryeverything24 Date: Wed, 22 Jul 2026 14:01:48 +0300 Subject: [PATCH] fix(wiki): resolve links by title before an earlier page's alias MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _link_index registered each page's title, id and alias in a single pass, so setdefault made link resolution depend on list_pages() order: a page holding a name only as an alias could shadow the page actually titled it when it sorted first. [[Retry Policy]] then resolved to the wrong page and its backlink landed on the alias holder in the render-wiki map-of-content, contradicting the module's promise that an alias never shadows a title. build the index in tiers instead — every title, then every id/slug, then every alias — so a real title always wins regardless of ordering. within a tier the earlier page still wins a genuine title-title collision. adds a regression test covering resolve_link and backlinks with an alias holder sorted ahead of the titled page. --- CHANGELOG.md | 12 ++++++++++++ src/vouch/wiki_render.py | 25 +++++++++++++++++++------ tests/test_wiki_render.py | 20 ++++++++++++++++++++ 3 files changed, 51 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0704c6a4..6c23b033 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,6 +64,18 @@ All notable changes to vouch are documented here. Format follows per-prompt block, the session banner, `vouch status` and the opt-in question all say so rather than calling it "this repo's" knowledge. +### Fixed +- **wiki render now resolves a `[[link]]` to the page actually titled that, + not to whichever page happens to list the name as an alias first** + (`wiki_render`). the link index registered each page's title, id and + aliases in one pass, so `setdefault` made resolution order-dependent: if a + page carrying "Retry Policy" only as an alias sorted ahead of the page + titled "Retry Policy", `[[Retry Policy]]` resolved to the wrong page and its + backlink landed on the alias holder in the `render-wiki` map-of-content — + contradicting the module's own promise that an alias never shadows a title. + the index is now built in tiers (every title, then every id/slug, then every + alias), so a real title always wins regardless of `list_pages()` order. + ## [1.5.0] — 2026-07-20 ### Added diff --git a/src/vouch/wiki_render.py b/src/vouch/wiki_render.py index 3d3c70b3..a9a8317a 100644 --- a/src/vouch/wiki_render.py +++ b/src/vouch/wiki_render.py @@ -21,15 +21,28 @@ def _link_index(pages: list[Page]) -> dict[str, Page]: """Map every resolvable name (title, id/slug, alias) to its page. - Keys are lowercased. Earlier pages win a name collision (``setdefault``), - so a later page's alias never shadows an existing page's title. + Keys are lowercased. Resolution is tiered so a stronger handle always beats + a weaker one regardless of page order: every title is registered first, + then every id/slug, then every alias, each with ``setdefault``. A page + titled ``Retry Policy`` therefore owns that name even when an earlier page + in the list carries it only as an alias — otherwise the resolved target + depended on ``list_pages()`` ordering. Within a single tier the earlier + page still wins a genuine collision (two pages sharing a title). """ index: dict[str, Page] = {} + + def register(name: object, page: Page) -> None: + key = str(name).strip().lower() + if key: + index.setdefault(key, page) + + for page in pages: + register(page.title, page) + for page in pages: + register(page.id, page) for page in pages: - for name in (page.title, page.id, *(page.metadata.get("aliases") or [])): - key = str(name).strip().lower() - if key: - index.setdefault(key, page) + for alias in page.metadata.get("aliases") or []: + register(alias, page) return index diff --git a/tests/test_wiki_render.py b/tests/test_wiki_render.py index fbb52216..43b4d722 100644 --- a/tests/test_wiki_render.py +++ b/tests/test_wiki_render.py @@ -84,3 +84,23 @@ def test_render_moc_ranks_by_inbound_links() -> None: # Gamma has 2 inbound links; it must rank above the 0-inbound pages. assert out.index("Gamma") < out.index("Alpha") assert out.index("Gamma") < out.index("Beta") + + +def test_title_beats_an_earlier_pages_alias_regardless_of_order() -> None: + # A real page is TITLED "Retry Policy"; an unrelated page carries the same + # string only as an alias and sorts earlier in the list. Resolution must + # follow the title, not the list order — the module docstring promises an + # alias never shadows an existing page's title. + aliaser = _page("Backoff Notes", aliases=["Retry Policy"], pid="backoff-notes") + titled = _page("Retry Policy", pid="retry-policy") + pages = [aliaser, titled] + + resolved = wiki_render.resolve_link("Retry Policy", pages) + assert resolved is titled + + # …and the backlink for a [[Retry Policy]] reference lands on the titled + # page, not the alias holder. + caller = _page("Caller", body="we follow [[Retry Policy]] here", pid="caller") + bl = wiki_render.backlinks([aliaser, titled, caller]) + assert bl.get("retry-policy") == ["Caller"] + assert "backoff-notes" not in bl