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